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
318 changed files with 3763 additions and 9000 deletions

View File

@@ -79,9 +79,6 @@ WORKDIR /app
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV PIP_NO_CACHE_DIR=1
# Use legacy resolver to work around broken build due to resolver changes in pip
ENV PIP_USE_DEPRECATED=legacy-resolver
# We first copy only the requirements file, to avoid rebuilding on every file
# change.
COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./

View File

@@ -20,7 +20,6 @@ module.exports = {
// allow debugger during development
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
"jsx-a11y/anchor-is-valid": "off",
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-restricted-imports": [
"error",
{

View File

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

View File

@@ -1,17 +1,12 @@
@backgroundColor: #001529;
@dividerColor: rgba(255, 255, 255, 0.5);
@textColor: rgba(255, 255, 255, 0.75);
@brandColor: #ff7964; // Redash logo color
@activeItemColor: @brandColor;
@iconSize: 26px;
.desktop-navbar {
background: @backgroundColor;
display: flex;
flex-direction: column;
height: 100%;
width: 80px;
overflow: hidden;
&-spacer {
flex: 1 1 auto;
@@ -26,6 +21,12 @@
height: 40px;
transition: all 270ms;
}
&.ant-menu-inline-collapsed {
img {
height: 20px;
}
}
}
.help-trigger {
@@ -33,19 +34,26 @@
}
.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-submenu {
font-weight: 500;
color: @textColor;
&.navbar-active-item {
box-shadow: inset 3px 0 0 @activeItemColor;
.anticon {
color: @activeItemColor;
}
}
&.ant-menu-submenu-open,
&.ant-menu-submenu-active,
&:hover,
@@ -53,16 +61,6 @@
color: #fff;
}
.anticon {
font-size: @iconSize;
margin: 0;
}
.desktop-navbar-label {
margin-top: 4px;
font-size: 11px;
}
a,
span,
.anticon {
@@ -73,33 +71,21 @@
.ant-menu-submenu-arrow {
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 {
width: 100%;
padding: 0;
.ant-btn.desktop-navbar-collapse-button {
background-color: @backgroundColor;
border: 0;
border-radius: 0;
color: @textColor;
&:hover,
&:active {
color: #fff;
}
a,
&.ant-menu-vertical > .ant-menu-submenu > .ant-menu-submenu-title,
.ant-menu-submenu-title {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: normal;
height: auto;
background: none;
color: inherit;
&:after {
animation: 0s !important;
}
}
@@ -113,8 +99,37 @@
.profile__image_thumb {
margin: 0;
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,4 +1,5 @@
import React, { useEffect, useState } from "react";
// @ts-expect-error (Must be removed after adding @redash/viz typing)
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
import { Auth } from "@/services/auth";
import { policy } from "@/services/policy";
@@ -61,10 +62,9 @@ export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserS
return (
<ApplicationLayout>
<React.Fragment key={currentRoute.key}>
{/* @ts-expect-error FIXME */}
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
<ErrorBoundaryContext.Consumer>
{({ handleError } /* : { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] } FIXME bring back type */) =>
{({ handleError }: { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] }) =>
render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
}
</ErrorBoundaryContext.Consumer>

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/ant";
@import '~antd/lib/button/style/index';
.code-block {
background: rgba(0, 0, 0, 0.06);

View File

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

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

@@ -45,7 +45,7 @@ export const TYPES = mapValues(
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"],
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"],
QUERIES: ["/user-guide/querying", "Guide: Queries"],
QUERIES: ["/help/user-guide/querying", "Guide: Queries"],
ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
},
([url, title]) => [DOMAIN + HELP_PATH + url, title]

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/ant";
@import "~antd/lib/drawer/style/drawer";
@help-doc-bg: #f7f7f7; // according to https://github.com/getredash/website/blob/13daff2d8b570956565f482236f6245042e8477f/src/scss/_components/_variables.scss#L15

View File

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

View File

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

View File

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

View File

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

View File

@@ -101,6 +101,7 @@ class ParameterValueInput extends React.Component {
<SelectWithVirtualScroll
className={this.props.className}
mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children"
value={normalize(value)}
onChange={this.onSelect}
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
@@ -119,6 +120,7 @@ class ParameterValueInput extends React.Component {
<QueryBasedParameterInput
className={this.props.className}
mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children"
parameter={parameter}
value={value}
queryId={queryId}

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/ant"; // for ant @vars
@import "~antd/lib/input-number/style/index"; // for ant @vars
@input-dirty: #fffce1;
@@ -21,7 +21,7 @@
.@{ant-prefix}-input-number,
.@{ant-prefix}-select-selector,
.@{ant-prefix}-picker {
background-color: @input-dirty;
background-color: @input-dirty !important;
}
}
}

View File

@@ -7,7 +7,6 @@ import { Parameter, createParameter } from "@/services/parameters";
import ParameterApplyButton from "@/components/ParameterApplyButton";
import ParameterValueInput from "@/components/ParameterValueInput";
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
import { toHuman } from "@/lib/utils";
import "./Parameters.less";
@@ -23,23 +22,19 @@ export default class Parameters extends React.Component {
static propTypes = {
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
editable: PropTypes.bool,
sortable: PropTypes.bool,
disableUrlUpdate: PropTypes.bool,
onValuesChange: PropTypes.func,
onPendingValuesChange: PropTypes.func,
onParametersEdit: PropTypes.func,
appendSortableToParent: PropTypes.bool,
};
static defaultProps = {
parameters: [],
editable: false,
sortable: false,
disableUrlUpdate: false,
onValuesChange: () => {},
onPendingValuesChange: () => {},
onParametersEdit: () => {},
appendSortableToParent: true,
};
constructor(props) {
@@ -89,7 +84,7 @@ export default class Parameters extends React.Component {
if (oldIndex !== newIndex) {
this.setState(({ parameters }) => {
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
onParametersEdit(parameters);
onParametersEdit();
return { parameters };
});
}
@@ -114,7 +109,7 @@ export default class Parameters extends React.Component {
this.setState(({ parameters }) => {
const updatedParameter = extend(parameter, updated);
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
onParametersEdit(parameters);
onParametersEdit();
return { parameters };
});
});
@@ -125,7 +120,7 @@ export default class Parameters extends React.Component {
return (
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
<div className="parameter-heading">
<label>{param.title || toHuman(param.name)}</label>
<label>{param.getTitle()}</label>
{editable && (
<button
className="btn btn-default btn-xs m-l-5"
@@ -150,17 +145,15 @@ export default class Parameters extends React.Component {
render() {
const { parameters } = this.state;
const { sortable, appendSortableToParent } = this.props;
const { editable } = this.props;
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
return (
<SortableContainer
disabled={!sortable}
disabled={!editable}
axis="xy"
useDragHandle
lockToContainerEdges
helperClass="parameter-dragged"
helperContainer={containerEl => (appendSortableToParent ? containerEl : document.body)}
updateBeforeSortStart={this.onBeforeSortStart}
onSortEnd={this.moveParameter}
containerProps={{
@@ -169,11 +162,8 @@ export default class Parameters extends React.Component {
}}>
{parameters.map((param, index) => (
<SortableElement key={param.name} index={index}>
<div
className="parameter-block"
data-editable={sortable || null}
data-test={`ParameterBlock-${param.name}`}>
{sortable && <DragHandle data-test={`DragHandle-${param.name}`} />}
<div className="parameter-block" data-editable={editable || null}>
{editable && <DragHandle data-test={`DragHandle-${param.name}`} />}
{this.renderParameter(param, index)}
</div>
</SortableElement>

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/ant";
@import "../assets/less/ant";
.parameter-block {
display: inline-block;
@@ -21,8 +21,6 @@
&.parameter-dragged {
z-index: 2;
margin: 4px 0 0 4px;
padding: 3px 6px 6px;
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
}
}

View File

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

View File

@@ -9,7 +9,7 @@ interface VirtualScrollLabeledValue extends LabeledValue {
label: string;
}
interface VirtualScrollSelectProps extends Omit<SelectProps<string>, "optionFilterProp" | "children"> {
interface VirtualScrollSelectProps extends SelectProps<string> {
options: Array<VirtualScrollLabeledValue>;
}
function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps): JSX.Element {
@@ -32,14 +32,7 @@ function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps
return false;
}, [options]);
return (
<AntdSelect<string>
dropdownMatchSelectWidth={dropdownMatchSelectWidth}
options={options}
optionFilterProp="label" // as this component expects "options" prop
{...props}
/>
);
return <AntdSelect<string> dropdownMatchSelectWidth={dropdownMatchSelectWidth} options={options} {...props} />;
}
export default SelectWithVirtualScroll;

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/ant";
@import "~@/assets/less/ant";
.tags-list {
.tags-list-title {

View File

@@ -35,11 +35,11 @@ CounterCard.defaultProps = {
const queryJobsColumns = [
{ title: "Queue", dataIndex: "origin" },
{ title: "Query ID", dataIndex: ["meta", "query_id"] },
{ title: "Org ID", dataIndex: ["meta", "org_id"] },
{ title: "Data Source ID", dataIndex: ["meta", "data_source_id"] },
{ title: "User ID", dataIndex: ["meta", "user_id"] },
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: ["meta", "scheduled"] }),
{ title: "Query ID", dataIndex: "meta.query_id" },
{ title: "Org ID", dataIndex: "meta.org_id" },
{ title: "Data Source ID", dataIndex: "meta.data_source_id" },
{ title: "User ID", dataIndex: "meta.user_id" },
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: "meta.scheduled" }),
Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }),
Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }),
];

View File

@@ -1,4 +1,5 @@
@import (reference, less) "~@/assets/less/inc/variables";
@import '../../assets/less/inc/variables';
.visual-card-list {
width: 100%;
@@ -6,7 +7,7 @@
}
.visual-card {
background: #ffffff;
background: #FFFFFF;
border: 1px solid fade(@redash-gray, 15%);
border-radius: 3px;
margin: 5px;

View File

@@ -41,7 +41,6 @@ const DashboardWidget = React.memo(
onRefreshWidget,
onRemoveWidget,
onParameterMappingsChange,
isEditing,
canEdit,
isPublic,
isLoading,
@@ -58,7 +57,6 @@ const DashboardWidget = React.memo(
widget={widget}
dashboard={dashboard}
filters={filters}
isEditing={isEditing}
canEdit={canEdit}
isPublic={isPublic}
isLoading={isLoading}
@@ -79,8 +77,7 @@ const DashboardWidget = React.memo(
prevProps.canEdit === nextProps.canEdit &&
prevProps.isPublic === nextProps.isPublic &&
prevProps.isLoading === nextProps.isLoading &&
prevProps.filters === nextProps.filters &&
prevProps.isEditing === nextProps.isEditing
prevProps.filters === nextProps.filters
);
class DashboardGrid extends React.Component {
@@ -226,6 +223,7 @@ class DashboardGrid extends React.Component {
});
render() {
const className = cx("dashboard-wrapper", this.props.isEditing ? "editing-mode" : "preview-mode");
const {
onLoadWidget,
onRefreshWidget,
@@ -234,21 +232,19 @@ class DashboardGrid extends React.Component {
filters,
dashboard,
isPublic,
isEditing,
widgets,
} = this.props;
const className = cx("dashboard-wrapper", isEditing ? "editing-mode" : "preview-mode");
return (
<div className={className}>
<ResponsiveGridLayout
draggableCancel="input,.sortable-container"
draggableCancel="input"
className={cx("layout", { "disable-animations": this.state.disableAnimations })}
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
rowHeight={cfg.rowHeight - cfg.margins}
margin={[cfg.margins, cfg.margins]}
isDraggable={isEditing}
isResizable={isEditing}
isDraggable={this.props.isEditing}
isResizable={this.props.isEditing}
onResizeStart={this.autoHeightCtrl.stop}
onResizeStop={this.onWidgetResize}
layouts={this.state.layouts}
@@ -270,7 +266,6 @@ class DashboardGrid extends React.Component {
filters={filters}
isPublic={isPublic}
isLoading={widget.loading}
isEditing={isEditing}
canEdit={dashboard.canEdit()}
onLoadWidget={onLoadWidget}
onRefreshWidget={onRefreshWidget}

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import { compact, isEmpty, invoke, map } from "lodash";
import { compact, isEmpty, invoke } from "lodash";
import { markdown } from "markdown";
import cx from "classnames";
import Menu from "antd/lib/menu";
@@ -84,14 +84,7 @@ function RefreshIndicator({ refreshStartedAt }) {
RefreshIndicator.propTypes = { refreshStartedAt: Moment };
RefreshIndicator.defaultProps = { refreshStartedAt: null };
function VisualizationWidgetHeader({
widget,
refreshStartedAt,
parameters,
isEditing,
onParametersUpdate,
onParametersEdit,
}) {
function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, onParametersUpdate }) {
const canViewQuery = currentUser.hasPermission("view_query");
return (
@@ -111,13 +104,7 @@ function VisualizationWidgetHeader({
</div>
{!isEmpty(parameters) && (
<div className="m-b-10">
<Parameters
parameters={parameters}
sortable={isEditing}
appendSortableToParent={false}
onValuesChange={onParametersUpdate}
onParametersEdit={onParametersEdit}
/>
<Parameters parameters={parameters} onValuesChange={onParametersUpdate} />
</div>
)}
</>
@@ -128,16 +115,12 @@ VisualizationWidgetHeader.propTypes = {
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
refreshStartedAt: Moment,
parameters: PropTypes.arrayOf(PropTypes.object),
isEditing: PropTypes.bool,
onParametersUpdate: PropTypes.func,
onParametersEdit: PropTypes.func,
};
VisualizationWidgetHeader.defaultProps = {
refreshStartedAt: null,
onParametersUpdate: () => {},
onParametersEdit: () => {},
isEditing: false,
parameters: [],
};
@@ -207,7 +190,6 @@ class VisualizationWidget extends React.Component {
isPublic: PropTypes.bool,
isLoading: PropTypes.bool,
canEdit: PropTypes.bool,
isEditing: PropTypes.bool,
onLoad: PropTypes.func,
onRefresh: PropTypes.func,
onDelete: PropTypes.func,
@@ -219,7 +201,6 @@ class VisualizationWidget extends React.Component {
isPublic: false,
isLoading: false,
canEdit: false,
isEditing: false,
onLoad: () => {},
onRefresh: () => {},
onDelete: () => {},
@@ -303,15 +284,10 @@ class VisualizationWidget extends React.Component {
}
render() {
const { widget, isLoading, isPublic, canEdit, isEditing, onRefresh } = this.props;
const { widget, isLoading, isPublic, canEdit, onRefresh } = this.props;
const { localParameters } = this.state;
const widgetQueryResult = widget.getQueryResult();
const isRefreshing = isLoading && !!(widgetQueryResult && widgetQueryResult.getStatus());
const onParametersEdit = parameters => {
const paramOrder = map(parameters, "name");
widget.options.paramOrder = paramOrder;
widget.save("options", { paramOrder });
};
return (
<Widget
@@ -327,9 +303,7 @@ class VisualizationWidget extends React.Component {
widget={widget}
refreshStartedAt={isRefreshing ? widget.refreshStartedAt : null}
parameters={localParameters}
isEditing={isEditing}
onParametersUpdate={onRefresh}
onParametersEdit={onParametersEdit}
/>
}
footer={

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/inc/variables";
@import "../../../assets/less/inc/variables";
.tile .t-header .th-title a.query-link {
color: rgba(0, 0, 0, 0.5);

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/ant";
@import "~@/assets/less/ant";
@btn-extra-options-bg: fade(@redash-gray, 10%);
@btn-extra-options-border: fade(@redash-gray, 15%);

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/inc/variables";
@import "../../assets/less/inc/variables";
.date-range-parameter,
.date-parameter {

View File

@@ -3,7 +3,7 @@
// Empty states
.empty-state {
width: 100%;
margin: 0 auto 10px;
margin: 0px auto 10px;
display: flex;
flex-direction: row;
justify-content: space-between;
@@ -18,7 +18,7 @@
}
.empty-state__steps {
padding-left: 0;
padding-left: 0px;
}
.empty-state__summary {

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/ant";
@import '~antd/lib/button/style/index';
.embed-query-dialog {
label {

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/main.less";
@import (reference, less) '~@/assets/less/main.less';
.ant-list {
&.add-to-dashboard-dialog-search-results {
@@ -13,8 +13,7 @@
padding: 12px;
cursor: pointer;
&:hover,
&:active {
&:hover, &:active {
@table-row-hover-bg: fade(@redash-gray, 5%);
background-color: @table-row-hover-bg;
}

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/ant";
@import "~@/assets/less/ant";
.databricks-schema-browser {
.schema-control {

View File

@@ -9,7 +9,6 @@ function getQueryResultData(queryResult, queryResultStatus = null) {
filters: invoke(queryResult, "getFilters") || [],
updatedAt: invoke(queryResult, "getUpdatedAt") || null,
retrievedAt: get(queryResult, "query_result.retrieved_at", null),
truncated: invoke(queryResult, "getTruncated") || null,
log: invoke(queryResult, "getLog") || [],
error: invoke(queryResult, "getError") || null,
runtime: invoke(queryResult, "getRuntime") || null,

View File

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

View File

@@ -30,13 +30,6 @@ const sidebarMenu = [
key: "all",
href: "dashboards",
title: "All Dashboards",
icon: () => <Sidebar.MenuIcon icon="zmdi zmdi-view-quilt" />,
},
{
key: "my",
href: "dashboards/my",
title: "My Dashboards",
icon: () => <Sidebar.ProfileImage user={currentUser} />,
},
{
key: "favorites",
@@ -164,7 +157,6 @@ const DashboardListPage = itemsList(
getResource({ params: { currentPage } }) {
return {
all: Dashboard.query.bind(Dashboard),
my: Dashboard.myDashboards.bind(Dashboard),
favorites: Dashboard.favorites.bind(Dashboard),
}[currentPage];
},
@@ -191,11 +183,3 @@ routes.register(
render: pageProps => <DashboardListPage {...pageProps} currentPage="favorites" />,
})
);
routes.register(
"Dashboards.My",
routeWithUserSession({
path: "/dashboards/my",
title: "My Dashboards",
render: pageProps => <DashboardListPage {...pageProps} currentPage="my" />,
})
);

View File

@@ -1,4 +1,4 @@
import { isEmpty, map } from "lodash";
import { isEmpty } from "lodash";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
@@ -24,8 +24,8 @@ import DashboardHeader from "./components/DashboardHeader";
import "./DashboardPage.less";
function DashboardSettings({ dashboardConfiguration }) {
const { dashboard, updateDashboard } = dashboardConfiguration;
function DashboardSettings({ dashboardOptions }) {
const { dashboard, updateDashboard } = dashboardOptions;
return (
<div className="m-b-10 p-15 bg-white tiled">
<Checkbox
@@ -39,11 +39,11 @@ function DashboardSettings({ dashboardConfiguration }) {
}
DashboardSettings.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
function AddWidgetContainer({ dashboardConfiguration, className, ...props }) {
const { showAddTextboxDialog, showAddWidgetDialog } = dashboardConfiguration;
function AddWidgetContainer({ dashboardOptions, className, ...props }) {
const { showAddTextboxDialog, showAddWidgetDialog } = dashboardOptions;
return (
<div className={cx("add-widget-container", className)} {...props}>
<h2>
@@ -66,12 +66,12 @@ function AddWidgetContainer({ dashboardConfiguration, className, ...props }) {
}
AddWidgetContainer.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
className: PropTypes.string,
};
function DashboardComponent(props) {
const dashboardConfiguration = useDashboard(props.dashboard);
const dashboardOptions = useDashboard(props.dashboard);
const {
dashboard,
filters,
@@ -81,19 +81,14 @@ function DashboardComponent(props) {
removeWidget,
saveDashboardLayout,
globalParameters,
updateDashboard,
refreshDashboard,
refreshWidget,
editingLayout,
setGridDisabled,
} = dashboardConfiguration;
} = dashboardOptions;
const [pageContainer, setPageContainer] = useState(null);
const [bottomPanelStyles, setBottomPanelStyles] = useState({});
const onParametersEdit = parameters => {
const paramOrder = map(parameters, "name");
updateDashboard({ options: { globalParamOrder: paramOrder } });
};
useEffect(() => {
if (pageContainer) {
@@ -119,23 +114,14 @@ function DashboardComponent(props) {
return (
<div className="container" ref={setPageContainer} data-test={`DashboardId${dashboard.id}Container`}>
<DashboardHeader
dashboardConfiguration={dashboardConfiguration}
dashboardOptions={dashboardOptions}
headerExtra={
<DynamicComponent
name="Dashboard.HeaderExtra"
dashboard={dashboard}
dashboardConfiguration={dashboardConfiguration}
/>
<DynamicComponent name="Dashboard.HeaderExtra" dashboard={dashboard} dashboardOptions={dashboardOptions} />
}
/>
{!isEmpty(globalParameters) && (
<div className="dashboard-parameters m-b-10 p-15 bg-white tiled" data-test="DashboardParameters">
<Parameters
parameters={globalParameters}
onValuesChange={refreshDashboard}
sortable={editingLayout}
onParametersEdit={onParametersEdit}
/>
<Parameters parameters={globalParameters} onValuesChange={refreshDashboard} />
</div>
)}
{!isEmpty(filters) && (
@@ -143,7 +129,7 @@ function DashboardComponent(props) {
<Filters filters={filters} onChange={setFilters} />
</div>
)}
{editingLayout && <DashboardSettings dashboardConfiguration={dashboardConfiguration} />}
{editingLayout && <DashboardSettings dashboardOptions={dashboardOptions} />}
<div id="dashboard-container">
<DashboardGrid
dashboard={dashboard}
@@ -158,9 +144,7 @@ function DashboardComponent(props) {
onParameterMappingsChange={loadDashboard}
/>
</div>
{editingLayout && (
<AddWidgetContainer dashboardConfiguration={dashboardConfiguration} style={bottomPanelStyles} />
)}
{editingLayout && <AddWidgetContainer dashboardOptions={dashboardOptions} style={bottomPanelStyles} />}
</div>
);
}

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/inc/variables";
@import "~@/assets/less/inc/variables";
/****
grid bg - based on 6 cols, 35px rows and 15px spacing

View File

@@ -27,8 +27,8 @@ function buttonType(value) {
return value ? "primary" : "default";
}
function DashboardPageTitle({ dashboardConfiguration }) {
const { dashboard, canEditDashboard, updateDashboard, editingLayout } = dashboardConfiguration;
function DashboardPageTitle({ dashboardOptions }) {
const { dashboard, canEditDashboard, updateDashboard, editingLayout } = dashboardOptions;
return (
<div className="title-with-tags">
<div className="page-title">
@@ -58,11 +58,11 @@ function DashboardPageTitle({ dashboardConfiguration }) {
}
DashboardPageTitle.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
function RefreshButton({ dashboardConfiguration }) {
const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardConfiguration;
function RefreshButton({ dashboardOptions }) {
const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardOptions;
const allowedIntervals = policy.getDashboardRefreshIntervals();
const refreshRateOptions = clientConfig.dashboardRefreshIntervals;
const onRefreshRateSelected = ({ key }) => {
@@ -105,10 +105,10 @@ function RefreshButton({ dashboardConfiguration }) {
}
RefreshButton.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
function DashboardMoreOptionsButton({ dashboardConfiguration }) {
function DashboardMoreOptionsButton({ dashboardOptions }) {
const {
dashboard,
setEditingLayout,
@@ -117,7 +117,7 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
managePermissions,
gridDisabled,
isDashboardOwnerOrAdmin,
} = dashboardConfiguration;
} = dashboardOptions;
const archive = () => {
Modal.confirm({
@@ -163,10 +163,10 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
}
DashboardMoreOptionsButton.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
function DashboardControl({ dashboardConfiguration, headerExtra }) {
function DashboardControl({ dashboardOptions, headerExtra }) {
const {
dashboard,
togglePublished,
@@ -174,7 +174,7 @@ function DashboardControl({ dashboardConfiguration, headerExtra }) {
fullscreen,
toggleFullscreen,
showShareDashboardDialog,
} = dashboardConfiguration;
} = dashboardOptions;
const showPublishButton = dashboard.is_draft;
const showRefreshButton = true;
const showFullscreenButton = !dashboard.is_draft;
@@ -190,7 +190,7 @@ function DashboardControl({ dashboardConfiguration, headerExtra }) {
<span className="fa fa-paper-plane m-r-5" /> Publish
</Button>
)}
{showRefreshButton && <RefreshButton dashboardConfiguration={dashboardConfiguration} />}
{showRefreshButton && <RefreshButton dashboardOptions={dashboardOptions} />}
{showFullscreenButton && (
<Tooltip className="hidden-xs" title="Enable/Disable Fullscreen display">
<Button type={buttonType(fullscreen)} className="icon-button m-l-5" onClick={toggleFullscreen}>
@@ -210,7 +210,7 @@ function DashboardControl({ dashboardConfiguration, headerExtra }) {
</Button>
</Tooltip>
)}
{showMoreOptionsButton && <DashboardMoreOptionsButton dashboardConfiguration={dashboardConfiguration} />}
{showMoreOptionsButton && <DashboardMoreOptionsButton dashboardOptions={dashboardOptions} />}
</span>
)}
</div>
@@ -218,17 +218,12 @@ function DashboardControl({ dashboardConfiguration, headerExtra }) {
}
DashboardControl.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
headerExtra: PropTypes.node,
};
function DashboardEditControl({ dashboardConfiguration, headerExtra }) {
const {
setEditingLayout,
doneBtnClickedWhileSaving,
dashboardStatus,
retrySaveDashboardLayout,
} = dashboardConfiguration;
function DashboardEditControl({ dashboardOptions, headerExtra }) {
const { setEditingLayout, doneBtnClickedWhileSaving, dashboardStatus, retrySaveDashboardLayout } = dashboardOptions;
let status;
if (dashboardStatus === DashboardStatusEnum.SAVED) {
status = <span className="save-status">Saved</span>;
@@ -263,23 +258,23 @@ function DashboardEditControl({ dashboardConfiguration, headerExtra }) {
}
DashboardEditControl.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
headerExtra: PropTypes.node,
};
export default function DashboardHeader({ dashboardConfiguration, headerExtra }) {
const { editingLayout } = dashboardConfiguration;
export default function DashboardHeader({ dashboardOptions, headerExtra }) {
const { editingLayout } = dashboardOptions;
const DashboardControlComponent = editingLayout ? DashboardEditControl : DashboardControl;
return (
<div className="dashboard-header">
<DashboardPageTitle dashboardConfiguration={dashboardConfiguration} />
<DashboardControlComponent dashboardConfiguration={dashboardConfiguration} headerExtra={headerExtra} />
<DashboardPageTitle dashboardOptions={dashboardOptions} />
<DashboardControlComponent dashboardOptions={dashboardOptions} headerExtra={headerExtra} />
</div>
);
}
DashboardHeader.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
headerExtra: PropTypes.node,
};

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/components/ApplicationArea/ApplicationLayout/index.less";
@import "~@/components/ApplicationArea/ApplicationLayout/index.less";
.dashboard-header {
display: flex;

View File

@@ -4,10 +4,6 @@ import BigMessage from "@/components/BigMessage";
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
import DynamicComponent from "@/components/DynamicComponent";
import Link from "@/components/Link";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import { currentUser } from "@/services/auth";
import HelpTrigger from "@/components/HelpTrigger";
export interface DashboardListEmptyStateProps {
page: string;
@@ -25,20 +21,6 @@ export default function DashboardListEmptyState({ page, searchTerm, selectedTags
switch (page) {
case "favorites":
return <BigMessage message="Mark dashboards as Favorite to list them here." icon="fa-star" />;
case "my":
const my_msg = currentUser.hasPermission("create_dashboard") ? (
<span>
<Link.Button type="primary" size="small" onClick={() => CreateDashboardDialog.showModal()}>
Create your first dashboard!
</Link.Button>{" "}
<HelpTrigger className="f-14" type="DASHBOARDS" showTooltip={false}>
Need help?
</HelpTrigger>
</span>
) : (
<span>Sorry, we couldn't find anything.</span>
);
return <BigMessage icon="fa-search">{my_msg}</BigMessage>;
default:
return (
<DynamicComponent name="DashboardList.EmptyState">

View File

@@ -33,13 +33,6 @@ const sidebarMenu = [
key: "all",
href: "queries",
title: "All Queries",
icon: () => <Sidebar.MenuIcon icon="fa fa-code" />,
},
{
key: "my",
href: "queries/my",
title: "My Queries",
icon: () => <Sidebar.ProfileImage user={currentUser} />,
},
{
key: "favorites",
@@ -47,6 +40,13 @@ const sidebarMenu = [
title: "Favorites",
icon: () => <Sidebar.MenuIcon icon="fa fa-star" />,
},
{
key: "my",
href: "queries/my",
title: "My Queries",
icon: () => <Sidebar.ProfileImage user={currentUser} />,
isAvailable: () => currentUser.hasPermission("create_query"),
},
{
key: "archive",
href: "queries/archive",

View File

@@ -5,8 +5,6 @@ import BigMessage from "@/components/BigMessage";
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
import DynamicComponent from "@/components/DynamicComponent";
import { currentUser } from "@/services/auth";
import HelpTrigger from "@/components/HelpTrigger";
export default function QueriesListEmptyState({ page, searchTerm, selectedTags }) {
if (searchTerm !== "") {
@@ -21,19 +19,15 @@ export default function QueriesListEmptyState({ page, searchTerm, selectedTags }
case "archive":
return <BigMessage message="Archived queries will be listed here." icon="fa-archive" />;
case "my":
const my_msg = currentUser.hasPermission("create_query") ? (
<span>
return (
<div className="tiled bg-white p-15">
<Link.Button href="queries/new" type="primary" size="small">
Create your first query!
Create your first query
</Link.Button>{" "}
<HelpTrigger className="f-13" type="QUERIES" showTooltip={false}>
Need help?
</HelpTrigger>
</span>
) : (
<span>Sorry, we couldn't find anything.</span>
to populate My Queries list. Need help? Check out our{" "}
<Link href="https://redash.io/help/user-guide/querying/writing-queries">query writing documentation</Link>.
</div>
);
return <BigMessage icon="fa-search">{my_msg}</BigMessage>;
default:
return (
<DynamicComponent name="QueriesList.EmptyState">

View File

@@ -336,7 +336,6 @@ function QuerySource(props) {
<div className="query-parameters-wrapper">
<Parameters
editable={queryFlags.canEdit}
sortable={queryFlags.canEdit}
disableUrlUpdate={queryFlags.isNew}
parameters={parameters}
onPendingValuesChange={() => updateParametersDirtyFlag()}

View File

@@ -1,8 +1,6 @@
import React from "react";
import PropTypes from "prop-types";
import WarningTwoTone from "@ant-design/icons/WarningTwoTone";
import TimeAgo from "@/components/TimeAgo";
import Tooltip from "antd/lib/tooltip";
import useAddToDashboardDialog from "../hooks/useAddToDashboardDialog";
import useEmbedDialog from "../hooks/useEmbedDialog";
import QueryControlDropdown from "@/components/EditVisualizationButton/QueryControlDropdown";
@@ -44,18 +42,6 @@ export default function QueryExecutionMetadata({
)}
<span className="m-l-5 m-r-10">
<span>
{queryResultData.truncated === true && (
<span className="m-r-5">
<Tooltip
title={
"Result truncated to " +
queryResultData.rows.length +
" rows. Databricks may truncate query results that are unstably large."
}>
<WarningTwoTone twoToneColor="#FF9800" />
</Tooltip>
</span>
)}
<strong>{queryResultData.rows.length}</strong> {pluralize("row", queryResultData.rows.length)}
</span>
<span className="m-l-5">

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ function OrganizationSettings({ onError }) {
{isLoading ? (
<Skeleton.Button active />
) : (
<Button type="primary" htmlType="submit" loading={isSaving} data-test="OrganizationSettingsSaveButton">
<Button type="primary" htmlType="submit" loading={isSaving}>
Save
</Button>
)}

View File

@@ -20,9 +20,7 @@ export default function FormatSettings(props) {
onChange={value => onChange({ date_format: value })}
data-test="DateFormatSelect">
{clientConfig.dateFormatList.map(dateFormat => (
<Select.Option key={dateFormat} data-test={`DateFormatSelect:${dateFormat}`}>
{dateFormat}
</Select.Option>
<Select.Option key={dateFormat}>{dateFormat}</Select.Option>
))}
</Select>
)}

View File

@@ -3,7 +3,6 @@ import { useState, useEffect, useCallback } from "react";
import recordEvent from "@/services/recordEvent";
import OrgSettings from "@/services/organizationSettings";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import { updateClientConfig } from "@/services/auth";
export default function useOrganizationSettings({ onError }) {
const [settings, setSettings] = useState({});
@@ -50,11 +49,6 @@ export default function useOrganizationSettings({ onError }) {
const settings = get(response, "settings");
setSettings(settings);
setCurrentValues({ ...settings });
updateClientConfig({
dateFormat: currentValues.date_format,
timeFormat: currentValues.time_format,
dateTimeFormat: `${currentValues.date_format} ${currentValues.time_format}`,
});
})
.catch(handleError)
.finally(() => setIsSaving(false));

View File

@@ -1,19 +1,19 @@
@import "./variables";
@import "variables";
@font-face {
font-family: "@{icomoon-font-family}";
src: url("@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm");
src: url("@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm#iefix") format("embedded-opentype"),
url("@{icomoon-font-path}/@{icomoon-font-family}.ttf?ehpufm") format("truetype"),
url("@{icomoon-font-path}/@{icomoon-font-family}.woff?ehpufm") format("woff"),
url("@{icomoon-font-path}/@{icomoon-font-family}.svg?ehpufm#@{icomoon-font-family}") format("svg");
font-family: '@{icomoon-font-family}';
src: url('@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm');
src: url('@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm#iefix') format('embedded-opentype'),
url('@{icomoon-font-path}/@{icomoon-font-family}.ttf?ehpufm') format('truetype'),
url('@{icomoon-font-path}/@{icomoon-font-family}.woff?ehpufm') format('woff'),
url('@{icomoon-font-path}/@{icomoon-font-family}.svg?ehpufm#@{icomoon-font-family}') format('svg');
font-weight: normal;
font-style: normal;
}
i.icon {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: "@{icomoon-font-family}" !important;
font-family: '@{icomoon-font-family}' !important;
speak: none;
font-style: normal;
font-weight: normal;
@@ -36,3 +36,4 @@ i.icon {
content: @icon-flash;
}
}

View File

@@ -44,10 +44,6 @@ const AuthUrls = {
Login: "login",
};
export function updateClientConfig(newClientConfig) {
extend(clientConfig, newClientConfig);
}
function updateSession(sessionData) {
logger("Updating session to be:", sessionData);
extend(session, sessionData, { loaded: true });

View File

@@ -168,7 +168,6 @@ const DashboardService = {
delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse),
query: params => axios.get("api/dashboards", { params }).then(transformResponse),
recent: params => axios.get("api/dashboards/recent", { params }).then(transformResponse),
myDashboards: params => axios.get("api/dashboards/my", { params }).then(transformResponse),
favorites: params => axios.get("api/dashboards/favorites", { params }).then(transformResponse),
favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`),
unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`),
@@ -209,19 +208,12 @@ Dashboard.prototype.getParametersDefs = function getParametersDefs() {
});
}
});
const resultingGlobalParams = _.values(
return _.values(
_.each(globalParams, param => {
param.setValue(param.value); // apply global param value to all locals
param.fromUrlParams(queryParams); // try to initialize from url (may do nothing)
})
);
// order dashboard params using paramOrder
return _.sortBy(resultingGlobalParams, param =>
_.includes(this.options.globalParamOrder, param.name)
? _.indexOf(this.options.globalParamOrder, param.name)
: _.size(this.options.globalParamOrder)
);
};
Dashboard.prototype.addWidget = function addWidget(textOrVisualization, options = {}) {

View File

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

View File

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

View File

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

View File

@@ -271,10 +271,6 @@ class QueryResult {
return this.getColumnNames().map(col => getColumnFriendlyName(col));
}
getTruncated() {
return this.query_result.data ? this.query_result.data.truncated : null;
}
getFilters() {
if (!this.getColumns()) {
return [];

View File

@@ -1,21 +1,6 @@
import moment from "moment";
import { axios } from "@/services/axios";
import {
each,
pick,
extend,
isObject,
truncate,
keys,
difference,
filter,
map,
merge,
sortBy,
indexOf,
size,
includes,
} from "lodash";
import { each, pick, extend, isObject, truncate, keys, difference, filter, map, merge } from "lodash";
import location from "@/services/location";
import { cloneParameter } from "@/services/parameters";
import dashboardGridOptions from "@/config/dashboard-grid-options";
@@ -222,7 +207,7 @@ class Widget {
const queryParams = location.search;
const localTypes = [Widget.MappingType.WidgetLevel, Widget.MappingType.StaticValue];
const localParameters = map(
return map(
filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0),
param => {
const mapping = mappings[param.name];
@@ -238,13 +223,6 @@ class Widget {
return result;
}
);
// order widget params using paramOrder
return sortBy(localParameters, param =>
includes(this.options.paramOrder, param.name)
? indexOf(this.options.paramOrder, param.name)
: size(this.options.paramOrder)
);
}
getParameterMappings() {

View File

@@ -0,0 +1,111 @@
import { createQueryAndAddWidget } from "../../support/dashboard";
describe("Parameter Mapping", () => {
beforeEach(function() {
cy.login();
cy.createDashboard("Foo Bar")
.then(({ id }) => {
this.dashboardId = id;
this.dashboardUrl = `/dashboards/${id}`;
})
.then(() => {
const queryData = {
name: "Text Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
options: {
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "text", value: "example" }],
},
};
const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(widgetTestId => {
cy.visit(this.dashboardUrl);
this.widgetTestId = widgetTestId;
});
});
});
const openMappingOptions = (widgetTestId, paramName) => {
cy.getByTestId(widgetTestId).within(() => {
cy.getByTestId("WidgetDropdownButton").click();
});
cy.getByTestId("WidgetDropdownButtonMenu")
.contains("Edit Parameters")
.click();
cy.getByTestId(`EditParamMappingButton-${paramName}`).click();
};
const saveMappingOptions = () => {
cy.getByTestId("InputPopoverContent").within(() => {
cy.contains("button", "OK").click();
});
cy.contains("button", "OK").click();
};
it("supports widget parameters", function() {
// widget parameter mapping is the default for the API
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("TableVisualization").should("contain", "example");
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.type("{selectall}Redash");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", "Redash");
});
cy.getByTestId("DashboardParameters").should("not.exist");
});
it("supports dashboard parameters", function() {
openMappingOptions(this.widgetTestId, "test-parameter");
cy.getByTestId("NewDashboardParameterOption").click();
saveMappingOptions();
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("ParameterName-test-parameter").should("not.exist");
});
cy.getByTestId("DashboardParameters").within(() => {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.type("{selectall}DashboardParam");
cy.getByTestId("ParameterApplyButton").click();
});
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("TableVisualization").should("contain", "DashboardParam");
});
});
it("supports static values for parameters", function() {
openMappingOptions(this.widgetTestId, "test-parameter");
cy.getByTestId("StaticValueOption").click();
cy.getByTestId("InputPopoverContent").within(() => {
cy.getByTestId("ParameterValueInput")
.find("input")
.type("{selectall}StaticValue");
});
saveMappingOptions();
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("ParameterName-test-parameter").should("not.exist");
});
cy.getByTestId("DashboardParameters").should("not.exist");
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("TableVisualization").should("contain", "StaticValue");
});
});
});

View File

@@ -1,164 +0,0 @@
import { createQueryAndAddWidget, editDashboard } from "../../support/dashboard";
import { dragParam, expectParamOrder } from "../../support/parameters";
describe("Dashboard Parameters", () => {
const parameters = [
{ name: "param1", title: "Parameter 1", type: "text", value: "example1" },
{ name: "param2", title: "Parameter 2", type: "text", value: "example2" },
];
beforeEach(function() {
cy.login();
cy.createDashboard("Foo Bar")
.then(({ id }) => {
this.dashboardId = id;
this.dashboardUrl = `/dashboards/${id}`;
})
.then(() => {
const queryData = {
name: "Text Parameter",
query: "SELECT '{{param1}}', '{{param2}}' AS parameter",
options: {
parameters,
},
};
const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(widgetTestId => {
cy.visit(this.dashboardUrl);
this.widgetTestId = widgetTestId;
});
});
});
const openMappingOptions = widgetTestId => {
cy.getByTestId(widgetTestId).within(() => {
cy.getByTestId("WidgetDropdownButton").click();
});
cy.getByTestId("WidgetDropdownButtonMenu")
.contains("Edit Parameters")
.click();
};
const saveMappingOptions = (closeMappingMenu = false) => {
return cy
.getByTestId("EditParamMappingPopover")
.filter(":visible")
.as("Popover")
.within(() => {
// This is needed to grant the element will have finished loading
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(500);
cy.contains("button", "OK").click();
})
.then(() => {
if (closeMappingMenu) {
cy.contains("button", "OK").click();
}
return cy.get("@Popover").should("not.be.visible");
});
};
const setWidgetParametersToDashboard = parameters => {
cy.wrap(parameters).each(({ name: paramName }, i) => {
cy.getByTestId(`EditParamMappingButton-${paramName}`).click();
cy.getByTestId("NewDashboardParameterOption")
.filter(":visible")
.click();
return saveMappingOptions(i === parameters.length - 1);
});
};
it("supports widget parameters", function() {
// widget parameter mapping is the default for the API
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("TableVisualization").should("contain", "example1");
cy.getByTestId("ParameterName-param1")
.find("input")
.type("{selectall}Redash");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", "Redash");
});
cy.getByTestId("DashboardParameters").should("not.exist");
});
it("supports dashboard parameters", function() {
openMappingOptions(this.widgetTestId);
setWidgetParametersToDashboard(parameters);
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("ParameterName-param1").should("not.exist");
});
cy.getByTestId("DashboardParameters").within(() => {
cy.getByTestId("ParameterName-param1")
.find("input")
.type("{selectall}DashboardParam");
cy.getByTestId("ParameterApplyButton").click();
});
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("TableVisualization").should("contain", "DashboardParam");
});
});
it("supports static values for parameters", function() {
openMappingOptions(this.widgetTestId);
cy.getByTestId("EditParamMappingButton-param1").click();
cy.getByTestId("StaticValueOption").click();
cy.getByTestId("EditParamMappingPopover").within(() => {
cy.getByTestId("ParameterValueInput")
.find("input")
.type("{selectall}StaticValue");
});
saveMappingOptions(true);
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("ParameterName-param1").should("not.exist");
});
cy.getByTestId("DashboardParameters").should("not.exist");
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("TableVisualization").should("contain", "StaticValue");
});
});
it("reorders parameters", function() {
// Reorder is only available in edit mode
editDashboard();
const [param1, param2] = parameters;
cy.getByTestId("ParameterBlock-param1")
.invoke("width")
.then(paramWidth => {
cy.server();
cy.route("POST", `**/api/dashboards/*`).as("SaveDashboard");
cy.route("POST", `**/api/widgets/*`).as("SaveWidget");
// Asserts widget param order
dragParam(param1.name, paramWidth, 1);
cy.wait("@SaveWidget");
cy.reload();
expectParamOrder([param2.title, param1.title]);
// Asserts dashboard param order
openMappingOptions(this.widgetTestId);
setWidgetParametersToDashboard(parameters);
cy.wait("@SaveWidget");
dragParam(param1.name, paramWidth, 1);
cy.wait("@SaveDashboard");
cy.reload();
expectParamOrder([param2.title, param1.title]);
});
});
});

View File

@@ -1,11 +1,3 @@
import { dragParam } from "../../support/parameters";
function openAndSearchAntdDropdown(testId, paramOption) {
cy.getByTestId(testId)
.find(".ant-select-selection-search-input")
.type(paramOption, { force: true });
}
describe("Parameter", () => {
const expectDirtyStateChange = edit => {
cy.getByTestId("ParameterName-test-parameter")
@@ -115,14 +107,12 @@ describe("Parameter", () => {
});
it("updates the results after selecting a value", () => {
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
// only the filtered option should be on the DOM
cy.get(".ant-select-item-option")
.should("have.length", 1)
.and("contain", "value2")
cy.getByTestId("ParameterName-test-parameter")
.find(".ant-select")
.click();
cy.contains(".ant-select-item-option", "value2").click();
cy.getByTestId("ParameterApplyButton").click();
// ensure that query is being executed
cy.getByTestId("QueryExecutionStatus").should("exist");
@@ -229,22 +219,6 @@ describe("Parameter", () => {
});
});
it("updates the results after selecting a value", () => {
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
// only the filtered option should be on the DOM
cy.get(".ant-select-item-option")
.should("have.length", 1)
.and("contain", "value2")
.click();
cy.getByTestId("ParameterApplyButton").click();
// ensure that query is being executed
cy.getByTestId("QueryExecutionStatus").should("exist");
cy.getByTestId("TableVisualization").should("contain", "2");
});
it("supports multi-selection", () => {
cy.clickThrough(`
ParameterSettings-test-parameter
@@ -601,6 +575,16 @@ describe("Parameter", () => {
cy.get("body").type("{alt}D"); // hide schema browser
});
const dragParam = (paramName, offsetLeft, offsetTop) => {
cy.getByTestId(`DragHandle-${paramName}`)
.trigger("mouseover")
.trigger("mousedown");
cy.get(".parameter-dragged .drag-handle")
.trigger("mousemove", offsetLeft, offsetTop, { force: true })
.trigger("mouseup", { force: true });
};
it("is possible to rearrange parameters", function() {
cy.server();
cy.route("POST", "**/api/queries/*").as("QuerySave");

View File

@@ -6,40 +6,10 @@ describe("Settings", () => {
it("renders the page and takes a screenshot", () => {
cy.getByTestId("OrganizationSettings").within(() => {
cy.getByTestId("DateFormatSelect").should("contain", "DD/MM/YY");
cy.getByTestId("TimeFormatSelect").should("contain", "HH:mm");
});
cy.percySnapshot("Organization Settings");
});
it("can set date format setting", () => {
cy.getByTestId("DateFormatSelect").click();
cy.getByTestId("DateFormatSelect:YYYY-MM-DD").click();
cy.getByTestId("OrganizationSettingsSaveButton").click();
cy.createQuery({
name: "test date format",
query: "SELECT NOW()",
}).then(({ id: queryId }) => {
cy.visit(`/queries/${queryId}`);
cy.findByText("Refresh Now").click();
// "created at" field is formatted with the date format.
cy.getByTestId("TableVisualization")
.findAllByText(/\d{4}-\d{2}-\d{2}/)
.should("exist");
// set to a different format and expect a different result in the table
cy.visit("/settings/general");
cy.getByTestId("DateFormatSelect").click();
cy.getByTestId("DateFormatSelect:MM/DD/YY").click();
cy.getByTestId("OrganizationSettingsSaveButton").click();
cy.visit(`/queries/${queryId}`);
cy.getByTestId("TableVisualization")
.findAllByText(/\d{2}\/\d{2}\/\d{2}/)
.should("exist");
});
});
});

View File

@@ -1,110 +0,0 @@
/* global cy */
import { getWidgetTestId } from "../../support/dashboard";
import {
assertAxesAndAddLabels,
assertPlotPreview,
assertTabbedEditor,
createChartThroughUI,
createDashboardWithCharts,
} from "../../support/visualizations/chart";
const SQL = `
SELECT 'a' AS stage, 11 AS value1, 22 AS value2 UNION ALL
SELECT 'a' AS stage, 12 AS value1, 41 AS value2 UNION ALL
SELECT 'a' AS stage, 45 AS value1, 93 AS value2 UNION ALL
SELECT 'a' AS stage, 54 AS value1, 79 AS value2 UNION ALL
SELECT 'b' AS stage, 33 AS value1, 65 AS value2 UNION ALL
SELECT 'b' AS stage, 73 AS value1, 50 AS value2 UNION ALL
SELECT 'b' AS stage, 90 AS value1, 40 AS value2 UNION ALL
SELECT 'c' AS stage, 19 AS value1, 33 AS value2 UNION ALL
SELECT 'c' AS stage, 92 AS value1, 14 AS value2 UNION ALL
SELECT 'c' AS stage, 63 AS value1, 65 AS value2 UNION ALL
SELECT 'c' AS stage, 44 AS value1, 27 AS value2\
`;
describe("Chart", () => {
beforeEach(() => {
cy.login();
cy.createQuery({ name: "Chart Visualization", query: SQL })
.its("id")
.as("queryId");
});
it("creates Bar charts", function() {
cy.visit(`queries/${this.queryId}/source`);
cy.getByTestId("ExecuteButton").click();
const getBarChartAssertionFunction = (specificBarChartAssertionFn = () => {}) => () => {
// checks for TabbedEditor standard tabs
assertTabbedEditor();
// standard chart should be bar
cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar");
// checks the plot canvas exists and is empty
assertPlotPreview("not.exist");
// creates a chart and checks it is plotted
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2");
assertPlotPreview("exist");
specificBarChartAssertionFn();
};
const chartTests = [
{
name: "Basic Bar Chart",
alias: "basicBarChart",
assertionFn: () => {
assertAxesAndAddLabels("Stage", "Value");
},
},
{
name: "Horizontal Bar Chart",
alias: "horizontalBarChart",
assertionFn: () => {
cy.getByTestId("Chart.SwappedAxes").check();
cy.getByTestId("VisualizationEditor.Tabs.XAxis").should("have.text", "Y Axis");
cy.getByTestId("VisualizationEditor.Tabs.YAxis").should("have.text", "X Axis");
},
},
{
name: "Stacked Bar Chart",
alias: "stackedBarChart",
assertionFn: () => {
cy.getByTestId("Chart.Stacking").selectAntdOption("Chart.Stacking.Stack");
},
},
{
name: "Normalized Bar Chart",
alias: "normalizedBarChart",
assertionFn: () => {
cy.getByTestId("Chart.NormalizeValues").check();
},
},
];
chartTests.forEach(({ name, alias, assertionFn }) => {
createChartThroughUI(name, getBarChartAssertionFunction(assertionFn)).as(alias);
});
const chartGetters = chartTests.map(({ alias }) => alias);
const withDashboardWidgetsAssertionFn = (widgetGetters, dashboardUrl) => {
cy.visit(dashboardUrl);
widgetGetters.forEach(widgetGetter => {
cy.get(`@${widgetGetter}`).then(widget => {
cy.getByTestId(getWidgetTestId(widget)).within(() => {
cy.get("g.points").should("exist");
});
});
});
};
createDashboardWithCharts("Bar chart visualizations", chartGetters, withDashboardWidgetsAssertionFn);
cy.percySnapshot("Visualizations - Charts - Bar");
});
});

View File

@@ -2,8 +2,6 @@
import "@percy/cypress"; // eslint-disable-line import/no-extraneous-dependencies, import/no-unresolved
import "@testing-library/cypress/add-commands";
const { each } = Cypress._;
Cypress.Commands.add("login", (email = "admin@redash.io", password = "password") => {

View File

@@ -1,13 +0,0 @@
export function dragParam(paramName, offsetLeft, offsetTop) {
cy.getByTestId(`DragHandle-${paramName}`)
.trigger("mouseover")
.trigger("mousedown");
cy.get(".parameter-dragged .drag-handle")
.trigger("mousemove", offsetLeft, offsetTop, { force: true })
.trigger("mouseup", { force: true });
}
export function expectParamOrder(expectedOrder) {
cy.get(".parameter-container label").each(($label, index) => expect($label).to.have.text(expectedOrder[index]));
}

View File

@@ -1,100 +0,0 @@
/**
* Asserts the preview canvas exists, then captures the g.points element, which should be generated by plotly and asserts whether it exists
* @param should Passed to should expression after plot points are captured
*/
export function assertPlotPreview(should = "exist") {
cy.getByTestId("VisualizationPreview")
.find("g.plot")
.should("exist")
.find("g.points")
.should(should);
}
export function createChartThroughUI(chartName, chartSpecificAssertionFn = () => {}) {
cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.CHART");
cy.getByTestId("VisualizationName")
.clear()
.type(chartName);
chartSpecificAssertionFn();
cy.server();
cy.route("POST", "**/api/visualizations").as("SaveVisualization");
cy.getByTestId("EditVisualizationDialog")
.contains("button", "Save")
.click();
cy.getByTestId("QueryPageVisualizationTabs")
.contains("span", chartName)
.should("exist");
cy.wait("@SaveVisualization").should("have.property", "status", 200);
return cy.get("@SaveVisualization").then(xhr => {
const { id, name, options } = xhr.response.body;
return cy.wrap({ id, name, options });
});
}
export function assertTabbedEditor(chartSpecificTabbedEditorAssertionFn = () => {}) {
cy.getByTestId("Chart.GlobalSeriesType").should("exist");
cy.getByTestId("VisualizationEditor.Tabs.Series").click();
cy.getByTestId("VisualizationEditor")
.find("table")
.should("exist");
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
cy.getByTestId("VisualizationEditor")
.find("table")
.should("exist");
cy.getByTestId("VisualizationEditor.Tabs.DataLabels").click();
cy.getByTestId("VisualizationEditor")
.getByTestId("Chart.DataLabels.ShowDataLabels")
.should("exist");
chartSpecificTabbedEditorAssertionFn();
cy.getByTestId("VisualizationEditor.Tabs.General").click();
}
export function assertAxesAndAddLabels(xaxisLabel, yaxisLabel) {
cy.getByTestId("VisualizationEditor.Tabs.XAxis").click();
cy.getByTestId("Chart.XAxis.Type")
.contains(".ant-select-selection-item", "Auto Detect")
.should("exist");
cy.getByTestId("Chart.XAxis.Name")
.clear()
.type(xaxisLabel);
cy.getByTestId("VisualizationEditor.Tabs.YAxis").click();
cy.getByTestId("Chart.LeftYAxis.Type")
.contains(".ant-select-selection-item", "Linear")
.should("exist");
cy.getByTestId("Chart.LeftYAxis.Name")
.clear()
.type(yaxisLabel);
cy.getByTestId("VisualizationEditor.Tabs.General").click();
}
export function createDashboardWithCharts(title, chartGetters, widgetsAssertionFn = () => {}) {
cy.createDashboard(title).then(dashboard => {
const dashboardUrl = `/dashboards/${dashboard.id}`;
const widgetGetters = chartGetters.map(chartGetter => `${chartGetter}Widget`);
chartGetters.forEach((chartGetter, i) => {
const position = { autoHeight: false, sizeY: 8, sizeX: 3, col: (i % 2) * 3 };
cy.get(`@${chartGetter}`)
.then(chart => cy.addWidget(dashboard.id, chart.id, { position }))
.as(widgetGetters[i]);
});
widgetsAssertionFn(widgetGetters, dashboardUrl);
});
}

View File

@@ -1,7 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["cypress", "@percy/cypress", "@testing-library/cypress"]
"types": ["cypress","@percy/cypress","@testing-library/cypress"]
},
"include": ["./**/*.ts"]
}

View File

@@ -15,14 +15,21 @@
"jsx": "react",
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"forceConsistentCasingInFileNames": true,
"baseUrl": "./",
"paths": {
"@/*": ["./app/*"]
}
},
"skipLibCheck": true
},
"include": ["app/**/*"],
"exclude": ["dist"]
"include": [
"app/**/*"
],
"exclude": [
"dist"
]
}

View File

@@ -1,28 +0,0 @@
"""empty message
Revision ID: 0ec979123ba4
Revises: e5c7a4e2df4d
Create Date: 2020-12-23 21:35:32.766354
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0ec979123ba4'
down_revision = 'e5c7a4e2df4d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('dashboards', sa.Column('options', postgresql.JSON(astext_type=sa.Text()), server_default='{}', nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('dashboards', 'options')
# ### end Alembic commands ###

View File

@@ -1,24 +0,0 @@
"""fix_multiple_heads
Revision ID: 89bc7873a3e0
Revises: 0ec979123ba4, d7d747033183
Create Date: 2021-01-21 18:11:04.312259
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '89bc7873a3e0'
down_revision = ('0ec979123ba4', 'd7d747033183')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -1,64 +0,0 @@
"""encrypt alert destinations
Revision ID: d7d747033183
Revises: e5c7a4e2df4d
Create Date: 2020-12-14 21:42:48.661684
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.sql import table
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
from redash import settings
from redash.utils.configuration import ConfigurationContainer
from redash.models.base import key_type
from redash.models.types import (
EncryptedConfiguration,
Configuration,
)
# revision identifiers, used by Alembic.
revision = 'd7d747033183'
down_revision = 'e5c7a4e2df4d'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"notification_destinations",
sa.Column("encrypted_options", postgresql.BYTEA(), nullable=True)
)
# copy values
notification_destinations = table(
"notification_destinations",
sa.Column("id", key_type("NotificationDestination"), primary_key=True),
sa.Column(
"encrypted_options",
ConfigurationContainer.as_mutable(
EncryptedConfiguration(
sa.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine
)
),
),
sa.Column("options", ConfigurationContainer.as_mutable(Configuration)),
)
conn = op.get_bind()
for dest in conn.execute(notification_destinations.select()):
conn.execute(
notification_destinations.update()
.where(notification_destinations.c.id == dest.id)
.values(encrypted_options=dest.options)
)
op.drop_column("notification_destinations", "options")
op.alter_column("notification_destinations", "encrypted_options", nullable=False)
def downgrade():
pass

2456
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -48,7 +48,7 @@
"@redash/viz": "file:viz-lib",
"ace-builds": "^1.4.12",
"antd": "^4.4.3",
"axios": "^0.21.1",
"axios": "^0.19.0",
"axios-auth-refresh": "^3.0.0",
"bootstrap": "^3.3.7",
"classnames": "^2.2.6",
@@ -67,9 +67,9 @@
"path-to-regexp": "^3.1.0",
"prop-types": "^15.6.1",
"query-string": "^6.9.0",
"react": "^16.14.0",
"react": "^16.13.1",
"react-ace": "^9.1.1",
"react-dom": "^16.14.0",
"react-dom": "^16.13.1",
"react-grid-layout": "^0.18.2",
"react-resizable": "^1.10.1",
"react-virtualized": "^9.21.2",
@@ -89,14 +89,12 @@
"@cypress/code-coverage": "^3.8.1",
"@percy/agent": "0.24.3",
"@percy/cypress": "^2.3.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
"@testing-library/cypress": "^7.0.2",
"@types/classnames": "^2.2.10",
"@types/hoist-non-react-statics": "^3.3.1",
"@types/lodash": "^4.14.157",
"@types/prop-types": "^15.7.3",
"@types/react": "^16.14.2",
"@types/react-dom": "^16.9.10",
"@types/react": "^16.9.41",
"@types/react-dom": "^16.9.8",
"@types/sql-formatter": "^2.3.0",
"@typescript-eslint/eslint-plugin": "^2.10.0",
"@typescript-eslint/parser": "^2.10.0",
@@ -139,18 +137,16 @@
"npm-run-all": "^4.1.5",
"prettier": "^1.19.1",
"raw-loader": "^0.5.1",
"react-refresh": "^0.9.0",
"react-test-renderer": "^16.5.2",
"request": "^2.88.0",
"request-cookies": "^1.1.0",
"style-loader": "^2.0.0",
"typescript": "^4.1.2",
"typescript": "^3.9.6",
"url-loader": "^1.1.2",
"webpack": "^4.44.2",
"webpack": "^4.20.2",
"webpack-build-notifier": "^0.1.30",
"webpack-bundle-analyzer": "^2.11.1",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.11.0",
"webpack-dev-server": "^3.1.9",
"webpack-manifest-plugin": "^2.0.4"
},
"optionalDependencies": {

View File

@@ -9,7 +9,7 @@ from sqlalchemy.sql import select
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
from redash import settings
from redash.models.base import Column, key_type
from redash.models.base import Column
from redash.models.types import EncryptedConfiguration
from redash.utils.configuration import ConfigurationContainer
@@ -86,11 +86,10 @@ def reencrypt(old_secret, new_secret, show_sql):
logging.basicConfig()
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
def _reencrypt_for_table(table_name, orm_name):
table_for_select = sqlalchemy.Table(
table_name,
"data_sources",
sqlalchemy.MetaData(),
Column("id", key_type(orm_name), primary_key=True),
Column("id", db.Integer, primary_key=True),
Column(
"encrypted_options",
ConfigurationContainer.as_mutable(
@@ -99,9 +98,9 @@ def reencrypt(old_secret, new_secret, show_sql):
),
)
table_for_update = sqlalchemy.Table(
table_name,
"data_sources",
sqlalchemy.MetaData(),
Column("id", key_type(orm_name), primary_key=True),
Column("id", db.Integer, primary_key=True),
Column(
"encrypted_options",
ConfigurationContainer.as_mutable(
@@ -111,15 +110,12 @@ def reencrypt(old_secret, new_secret, show_sql):
)
update = table_for_update.update()
selected_items = db.session.execute(select([table_for_select]))
for item in selected_items:
stmt = update.where(table_for_update.c.id == item["id"]).values(
encrypted_options=item["encrypted_options"]
data_sources = db.session.execute(select([table_for_select]))
for ds in data_sources:
stmt = update.where(table_for_update.c.id == ds["id"]).values(
encrypted_options=ds["encrypted_options"]
)
db.session.execute(stmt)
selected_items.close()
data_sources.close()
db.session.commit()
_reencrypt_for_table("data_sources", "DataSource")
_reencrypt_for_table("notification_destinations", "NotificationDestination")

View File

@@ -50,22 +50,30 @@ def worker(queues):
class WorkerHealthcheck(base.BaseCheck):
NAME = "RQ Worker Healthcheck"
NAME = 'RQ Worker Healthcheck'
INTERVAL = datetime.timedelta(minutes=5)
_last_check_time = {}
def __call__(self, process_spec):
pid = process_spec["pid"]
all_workers = Worker.all(connection=rq_redis_connection)
workers = [
w
for w in all_workers
if w.hostname == socket.gethostname() and w.pid == pid
]
def time_to_check(self, pid):
now = datetime.datetime.utcnow()
if pid not in self._last_check_time:
self._last_check_time[pid] = now
if now - self._last_check_time[pid] >= self.INTERVAL:
self._last_check_time[pid] = now
return True
if not workers:
self._log(f"Cannot find worker for hostname {socket.gethostname()} and pid {pid}. ==> Is healthy? False")
return False
worker = workers.pop()
def __call__(self, process_spec):
pid = process_spec['pid']
if not self.time_to_check(pid):
return True
all_workers = Worker.all(connection=rq_redis_connection)
worker = [w for w in all_workers if w.hostname == socket.gethostname().encode() and
w.pid == pid].pop()
is_busy = worker.get_state() == WorkerStatus.BUSY
@@ -77,19 +85,12 @@ class WorkerHealthcheck(base.BaseCheck):
is_healthy = is_busy or seen_lately or has_nothing_to_do
self._log(
"Worker %s healthcheck: Is busy? %s. "
self._log("Worker %s healthcheck: Is busy? %s. "
"Seen lately? %s (%d seconds ago). "
"Has nothing to do? %s (%d jobs in watched queues). "
"==> Is healthy? %s",
worker.key,
is_busy,
seen_lately,
time_since_seen.seconds,
has_nothing_to_do,
total_jobs_in_watched_queues,
is_healthy,
)
worker.key, is_busy, seen_lately, time_since_seen.seconds,
has_nothing_to_do, total_jobs_in_watched_queues, is_healthy)
return is_healthy
@@ -97,5 +98,4 @@ class WorkerHealthcheck(base.BaseCheck):
@manager.command()
def healthcheck():
return check_runner.CheckRunner(
"worker_healthcheck", "worker", None, [(WorkerHealthcheck, {})]
).run()
'worker_healthcheck', 'worker', None, [(WorkerHealthcheck, {})]).run()

View File

@@ -22,7 +22,6 @@ class ChatWork(BaseDestination):
"title": "Message Template",
},
},
"secret": ["api_token"],
"required": ["message_template", "api_token", "room_id"],
}

View File

@@ -28,7 +28,6 @@ class HangoutsChat(BaseDestination):
"title": "Icon URL (32x32 or multiple, png format)",
},
},
"secret": ["url"],
"required": ["url"],
}

View File

@@ -25,7 +25,6 @@ class HipChat(BaseDestination):
"title": "HipChat Notification URL (get it from the Integrations page)",
}
},
"secret": ["url"],
"required": ["url"],
}

View File

@@ -16,7 +16,6 @@ class Mattermost(BaseDestination):
"icon_url": {"type": "string", "title": "Icon (URL)"},
"channel": {"type": "string", "title": "Channel"},
},
"secret": "url"
}
@classmethod

View File

@@ -32,7 +32,6 @@ class PagerDuty(BaseDestination):
"title": "Description for the event, defaults to alert name",
},
},
"secret": ["integration_key"],
"required": ["integration_key"],
}

View File

@@ -17,7 +17,6 @@ class Slack(BaseDestination):
"icon_url": {"type": "string", "title": "Icon (URL)"},
"channel": {"type": "string", "title": "Channel"},
},
"secret": ["url"]
}
@classmethod

View File

@@ -18,7 +18,7 @@ class Webhook(BaseDestination):
"password": {"type": "string"},
},
"required": ["url"],
"secret": ["password", "url"],
"secret": ["password"],
}
@classmethod

View File

@@ -11,7 +11,6 @@ from redash.handlers.alerts import (
)
from redash.handlers.base import org_scoped_rule
from redash.handlers.dashboards import (
MyDashboardsResource,
DashboardFavoriteListResource,
DashboardListResource,
DashboardResource,
@@ -210,8 +209,6 @@ api.add_org_resource(
endpoint="dashboard_favorite",
)
api.add_org_resource(MyDashboardsResource, "/api/dashboards/my", endpoint="my_dashboards")
api.add_org_resource(QueryTagsResource, "/api/queries/tags", endpoint="query_tags")
api.add_org_resource(
DashboardTagsResource, "/api/dashboards/tags", endpoint="dashboard_tags"

View File

@@ -113,43 +113,6 @@ class DashboardListResource(BaseResource):
return DashboardSerializer(dashboard).serialize()
class MyDashboardsResource(BaseResource):
@require_permission("list_dashboards")
def get(self):
"""
Retrieve a list of dashboards created by the current user.
:qparam number page_size: Number of dashboards to return per page
:qparam number page: Page number to retrieve
:qparam number order: Name of column to order by
:qparam number search: Full text search term
Responds with an array of :ref:`dashboard <dashboard-response-label>`
objects.
"""
search_term = request.args.get("q", "")
if search_term:
results = models.Dashboard.search_by_user(search_term, self.current_user)
else:
results = models.Dashboard.by_user(self.current_user)
results = filter_by_tags(results, models.Dashboard.tags)
# order results according to passed order parameter,
# special-casing search queries where the database
# provides an order by search rank
ordered_results = order_results(results, fallback=not bool(search_term))
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 25, type=int)
return paginate(
ordered_results,
page,
page_size,
DashboardSerializer
)
class DashboardResource(BaseResource):
@require_permission("list_dashboards")
def get(self, dashboard_id=None):
@@ -172,7 +135,6 @@ class DashboardResource(BaseResource):
:>json boolean is_draft: Whether this dashboard is a draft or not.
:>json array layout: Array of arrays containing widget IDs, corresponding to the rows and columns the widgets are displayed in
:>json array widgets: Array of arrays containing :ref:`widget <widget-response-label>` data
:>json object options: Dashboard options
.. _widget-response-label:
@@ -243,7 +205,6 @@ class DashboardResource(BaseResource):
"is_draft",
"is_archived",
"dashboard_filters_enabled",
"options",
),
)

View File

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

View File

@@ -1099,9 +1099,6 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
tags = Column(
"tags", MutableList.as_mutable(postgresql.ARRAY(db.Unicode)), nullable=True
)
options = Column(
MutableDict.as_mutable(postgresql.JSON), server_default="{}", default={}
)
__tablename__ = "dashboards"
__mapper_args__ = {"version_id_col": version}
@@ -1135,6 +1132,7 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
),
Dashboard.org == org,
)
.distinct()
)
query = query.filter(
@@ -1150,10 +1148,6 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
cls.name.ilike("%{}%".format(search_term))
)
@classmethod
def search_by_user(cls, term, user, limit=None):
return cls.by_user(user).filter(cls.name.ilike("%{}%".format(term))).limit(limit)
@classmethod
def all_tags(cls, org, user):
dashboards = cls.all(org, user.group_ids, user.id)
@@ -1183,10 +1177,6 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
)
).filter(Favorite.user_id == user.id)
@classmethod
def by_user(cls, user):
return cls.all(user.org, user.group_ids, user.id).filter(Dashboard.user == user)
@classmethod
def get_by_slug_and_org(cls, slug, org):
return cls.query.filter(cls.slug == slug, cls.org == org).one()
@@ -1361,14 +1351,7 @@ class NotificationDestination(BelongsToOrgMixin, db.Model):
user = db.relationship(User, backref="notification_destinations")
name = Column(db.String(255))
type = Column(db.String(255))
options = Column(
"encrypted_options",
ConfigurationContainer.as_mutable(
EncryptedConfiguration(
db.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine
)
),
)
options = Column(ConfigurationContainer.as_mutable(Configuration))
created_at = Column(db.DateTime(True), default=db.func.now())
__tablename__ = "notification_destinations"

View File

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

View File

@@ -1,6 +1,4 @@
import datetime
import logging
import os
import sqlparse
from redash.query_runner import (
NotSupported,
@@ -13,9 +11,8 @@ from redash.query_runner import (
TYPE_INTEGER,
TYPE_FLOAT,
)
from redash.settings import cast_int_or_default
from redash.utils import json_dumps, json_loads
from redash import __version__, settings, statsd_client
from redash import __version__
try:
import pyodbc
@@ -33,9 +30,6 @@ TYPES_MAP = {
float: TYPE_FLOAT,
}
ROW_LIMIT = cast_int_or_default(os.environ.get("DATABRICKS_ROW_LIMIT"), 20000)
logger = logging.getLogger(__name__)
def _build_odbc_connection_string(**kwargs):
return ";".join([f"{k}={v}" for k, v in kwargs.items()])
@@ -46,10 +40,8 @@ def split_sql_statements(query):
idx = len(stmt.tokens) - 1
while idx >= 0:
tok = stmt.tokens[idx]
if tok.is_whitespace or sqlparse.utils.imt(
tok, i=sqlparse.sql.Comment, t=sqlparse.tokens.Comment
):
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, " ")
if tok.is_whitespace or sqlparse.utils.imt(tok, i=sqlparse.sql.Comment, t=sqlparse.tokens.Comment):
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, ' ')
else:
break
idx -= 1
@@ -61,13 +53,8 @@ def split_sql_statements(query):
tok = stmt.tokens[idx]
# we expect that trailing comments already are removed
if not tok.is_whitespace:
if (
sqlparse.utils.imt(tok, t=sqlparse.tokens.Punctuation)
and tok.value == ";"
):
stmt.tokens[idx] = sqlparse.sql.Token(
sqlparse.tokens.Whitespace, " "
)
if sqlparse.utils.imt(tok, t=sqlparse.tokens.Punctuation) and tok.value == ";":
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, ' ')
break
idx -= 1
return stmt
@@ -87,11 +74,7 @@ def split_sql_statements(query):
result = [stmt for stmt in stack.run(query)]
result = [strip_trailing_comments(stmt) for stmt in result]
result = [strip_trailing_semicolon(stmt) for stmt in result]
result = [
sqlparse.text_type(stmt).strip()
for stmt in result
if not is_empty_statement(stmt)
]
result = [sqlparse.text_type(stmt).strip() for stmt in result if not is_empty_statement(stmt)]
if len(result) > 0:
return result
@@ -164,7 +147,7 @@ class Databricks(BaseSQLQueryRunner):
cursor.execute(stmt)
if cursor.description is not None:
result_set = cursor.fetchmany(ROW_LIMIT)
data = cursor.fetchall()
columns = self.fetch_columns(
[
(i[0], TYPES_MAP.get(i[1], TYPE_STRING))
@@ -174,18 +157,10 @@ class Databricks(BaseSQLQueryRunner):
rows = [
dict(zip((column["name"] for column in columns), row))
for row in result_set
for row in data
]
data = {"columns": columns, "rows": rows}
if (
len(result_set) >= ROW_LIMIT
and cursor.fetchone() is not None
):
logger.warning("Truncated result set.")
statsd_client.incr("redash.query_runner.databricks.truncated")
data["truncated"] = True
json_data = json_dumps(data)
error = None
else:

View File

@@ -133,8 +133,6 @@ class MongoDB(BaseQueryRunner):
"type": "object",
"properties": {
"connectionString": {"type": "string", "title": "Connection String"},
"username": {"type": "string"},
"password": {"type": "string"},
"dbName": {"type": "string", "title": "Database Name"},
"replicaSetName": {"type": "string", "title": "Replica Set Name"},
"readPreference": {
@@ -149,7 +147,6 @@ class MongoDB(BaseQueryRunner):
"title": "Replica Set Read Preference",
},
},
"secret": ["password"],
"required": ["connectionString", "dbName"],
}
@@ -179,12 +176,6 @@ class MongoDB(BaseQueryRunner):
if readPreference:
kwargs["readPreference"] = readPreference
if "username" in self.configuration:
kwargs["username"] = self.configuration["username"]
if "password" in self.configuration:
kwargs["password"] = self.configuration["password"]
db_connection = pymongo.MongoClient(
self.configuration["connectionString"], **kwargs
)

View File

@@ -169,7 +169,7 @@ class PostgreSQL(BaseSQLQueryRunner):
},
"order": ["host", "port", "user", "password"],
"required": ["dbname"],
"secret": ["password", "sslrootcertFile", "sslcertFile", "sslkeyFile"],
"secret": ["password"],
"extra_options": [
"sslmode",
"sslrootcertFile",

View File

@@ -53,7 +53,6 @@ class TreasureData(BaseQueryRunner):
"default": False,
},
},
"secret": ["apikey"],
"required": ["apikey", "db"],
}

View File

@@ -89,7 +89,6 @@ class YandexMetrica(BaseSQLQueryRunner):
return {
"type": "object",
"properties": {"token": {"type": "string", "title": "OAuth Token"}},
"secret": ["token"],
"required": ["token"],
}

View File

@@ -55,7 +55,7 @@ def public_widget(widget):
def public_dashboard(dashboard):
dashboard_dict = project(
serialize_dashboard(dashboard, with_favorite_state=False),
("name", "layout", "dashboard_filters_enabled", "updated_at", "created_at", "options"),
("name", "layout", "dashboard_filters_enabled", "updated_at", "created_at"),
)
widget_list = (
@@ -257,7 +257,6 @@ def serialize_dashboard(obj, with_widgets=False, user=None, with_favorite_state=
"layout": layout,
"dashboard_filters_enabled": obj.dashboard_filters_enabled,
"widgets": widgets,
"options": obj.options,
"is_archived": obj.is_archived,
"is_draft": obj.is_draft,
"tags": obj.tags or [],

View File

@@ -11,7 +11,6 @@ from .helpers import (
int_or_none,
set_from_string,
add_decode_responses_to_redis_url,
cast_int_or_default
)
from .organization import DATE_FORMAT, TIME_FORMAT # noqa
@@ -305,7 +304,7 @@ RATELIMIT_ENABLED = parse_boolean(os.environ.get("REDASH_RATELIMIT_ENABLED", "tr
THROTTLE_LOGIN_PATTERN = os.environ.get("REDASH_THROTTLE_LOGIN_PATTERN", "50/hour")
LIMITER_STORAGE = os.environ.get("REDASH_LIMITER_STORAGE", REDIS_URL)
# CORS settings for the Query Result API (and possibly future external APIs).
# CORS settings for the Query Result API (and possbily future external APIs).
# In most cases all you need to do is set REDASH_CORS_ACCESS_CONTROL_ALLOW_ORIGIN
# to the calling domain (or domains in a comma separated list).
ACCESS_CONTROL_ALLOW_ORIGIN = set_from_string(
@@ -512,6 +511,4 @@ ENFORCE_CSRF = parse_boolean(
os.environ.get("REDASH_ENFORCE_CSRF", "false")
)
# Databricks
CSRF_TIME_LIMIT = int(os.environ.get("REDASH_CSRF_TIME_LIMIT", 3600 * 6))

View File

@@ -61,14 +61,3 @@ def database_key_definitions(default):
# Since you can define custom primary key types using `database_key_definitions`, you may want to load certain extensions when creating the database.
# To do so, simply add the name of the extension you'd like to load to this list.
database_extensions = []
# If you'd like to limit the amount of concurrent query executions made by a certain org or user,
# implement this method by returning a boolean which would indicate if the limit has reached.
# If you return `True`, the query execution would move to a waiting list and would only be executed
# when a spot clears up for it within the defined capacity.
# `entity` is either "user" or "org".
# `executions` is the number of currently running query execution jobs for the specific user/org.
# `meta` is the query execution job's meta attribute.
def capacity_reached_for(entity, executions, meta):
return False

View File

@@ -29,11 +29,6 @@ def parse_boolean(s):
else:
raise ValueError("Invalid boolean value %r" % s)
def cast_int_or_default(val, default=None):
try:
return int(val)
except (ValueError, TypeError):
return default
def int_or_none(value):
if value is None:

View File

@@ -3,6 +3,7 @@ from .general import (
version_check,
send_mail,
sync_user_details,
purge_failed_jobs,
)
from .queries import (
enqueue_query,
@@ -16,7 +17,6 @@ from .queries import (
from .alerts import check_alerts_for_query
from .failure_report import send_aggregated_errors
from .worker import Worker, Queue, Job
from .capacity import cleanup_waiting_lists
from .schedule import rq_scheduler, schedule_periodic_jobs, periodic_job_definitions
from redash import rq_redis_connection

View File

@@ -1,135 +0,0 @@
import re
import itertools
import logging
from rq import Queue, Worker
from rq.job import Job
from redash import settings
logger = logging.getLogger(__name__)
def cleanup_waiting_lists():
"""
When a job is enqueued/dequeued to/from a CapacityQueue and it exceeds the org/user capacity, it is entered into a waiting list.
Later on, when a CapacityWorker finishes work on a job and a slot for a job on the waiting list becomes available, the worker will trigger the corresponding job
on the waiting list and re-queue it back to the original queue.
However, if a (non-horse) worker dies in the middle of execution, it will not trigger the next item on the waiting list. If there is any other
job for that org or user queued or executing, they will trigger those jobs eventually, but if no other jobs are queued or executing, the jobs
on the waiting list may never execute.
This periodic task looks at all waiting lists and sees if there are no triggers for any of them. In case no triggers are found, we can assume that
their worker died and re-enqueue them back into their original queues.
If a waiting list is empty, it can be deleted.
"""
queues = set(Queue.all())
waiting_lists = set([q for q in queues if q.name.endswith(":waiting")])
wip = itertools.chain(
*[
queue.started_job_registry.get_job_ids()
for queue in (queues - waiting_lists)
]
)
for waiting_list in waiting_lists:
trigger = next(
(j for j in wip if waiting_list.name.split(":origin")[0] in j), None
)
if trigger is None:
if waiting_list.is_empty():
logger.warning(
f"Waiting list {waiting_list.name} is empty and will be deleted."
)
waiting_list.delete()
else:
origin_name = re.findall(r"origin:(.*?):", waiting_list.name)[0]
logger.warning(
f"Waiting list {waiting_list.name} has no executing job to trigger it. Returning all jobs from the waiting list back to their original queue ({origin_name})."
)
origin = CapacityQueue(origin_name)
while waiting_list.count > 0:
job_id = waiting_list.pop_job_id()
job = Job.fetch(job_id)
origin.enqueue_job(job, at_front=True)
entity_key = lambda entity, job: f"{entity}:{job.meta[f'{entity}_id']}"
waiting_list_key = (
lambda entity, job, origin_name: f"{entity_key(entity, job)}:origin:{origin_name}:waiting"
)
class CapacityQueue(Queue):
def find_waiting_list(self, job_ids, entity, job):
executions = sum(map(lambda job_id: entity_key(entity, job) in job_id, job_ids))
if settings.dynamic_settings.capacity_reached_for(entity, executions, job.meta):
waiting_list = waiting_list_key(entity, job, self.name)
logger.warning(
f"Moving job {job.id} to the {entity}'s waiting list ({waiting_list}) since {entity_key(entity, job)} is currently executing {executions} jobs and has reached the {entity} capacity."
)
return waiting_list
def enter_waiting_list(self, job, pipeline=None):
if job.meta.get("is_query_execution", False):
job_ids = self.started_job_registry.get_job_ids()
waiting_list = self.find_waiting_list(
job_ids, "user", job
) or self.find_waiting_list(job_ids, "org", job)
if waiting_list:
return Queue(waiting_list).enqueue_job(job, pipeline=pipeline)
@classmethod
def dequeue_any(cls, *args, **kwargs):
result = super(CapacityQueue, cls).dequeue_any(*args, **kwargs)
if result is None:
return None
job, queue = result
if queue.enter_waiting_list(job):
return cls.dequeue_any(*args, **kwargs)
else:
return job, queue
def enqueue_job(self, job, pipeline=None, at_front=False):
return self.enter_waiting_list(job, pipeline) or super().enqueue_job(
job, pipeline=pipeline, at_front=at_front
)
class CapacityWorker(Worker):
queue_class = CapacityQueue
def _process_waiting_lists(self, queue, job):
if job.meta.get("is_query_execution", False):
waiting_lists = [
Queue(waiting_list_key("user", job, queue.name)),
Queue(waiting_list_key("org", job, queue.name)),
]
result = Queue.dequeue_any(waiting_lists, None, job_class=self.job_class)
if result is not None:
waiting_job, _ = result
logger.warning(
f"Moving job {waiting_job.id} from waiting list ({waiting_job.origin}) back to the original queue ({queue.name}) since an execution slot opened up for it."
)
queue.enqueue_job(waiting_job)
def handle_job_success(self, job, queue, started_job_registry):
try:
super().handle_job_success(job, queue, started_job_registry)
finally:
self._process_waiting_lists(queue, job)
def handle_job_failure(self, job, queue, started_job_registry=None, exc_string=""):
try:
super().handle_job_failure(job, queue, started_job_registry, exc_string)
finally:
self._process_waiting_lists(queue, job)

View File

@@ -2,10 +2,13 @@ import requests
from datetime import datetime
from flask_mail import Message
from redash import mail, models, settings
from rq import Connection, Queue
from rq.registry import FailedJobRegistry
from rq.job import Job
from redash import mail, models, settings, rq_redis_connection
from redash.models import users
from redash.version_check import run_version_check
from redash.worker import job, get_job_logger
from redash.worker import job, get_job_logger, default_operational_queues
from redash.tasks.worker import Queue
from redash.query_runner import NotSupported
@@ -91,3 +94,35 @@ def get_schema(data_source_id, refresh):
def sync_user_details():
users.sync_last_active_at()
def purge_failed_jobs():
with Connection(rq_redis_connection):
queues = [q for q in Queue.all() if q.name not in default_operational_queues]
for queue in queues:
failed_job_ids = FailedJobRegistry(queue=queue).get_job_ids()
failed_jobs = Job.fetch_many(failed_job_ids, rq_redis_connection)
stale_jobs = []
for failed_job in failed_jobs:
# the job may not actually exist anymore in Redis
if not failed_job:
continue
# the job could have an empty ended_at value in case
# of a worker dying before it can save the ended_at value,
# in which case we also consider them stale
if not failed_job.ended_at:
stale_jobs.append(failed_job)
elif (
datetime.utcnow() - failed_job.ended_at
).total_seconds() > settings.JOB_DEFAULT_FAILURE_TTL:
stale_jobs.append(failed_job)
for stale_job in stale_jobs:
stale_job.delete()
if stale_jobs:
logger.info(
"Purged %d old failed jobs from the %s queue.",
len(stale_jobs),
queue.name,
)

View File

@@ -1,7 +1,6 @@
import signal
import time
import redis
from uuid import uuid4
from rq import get_current_job
from rq.job import JobStatus
@@ -87,14 +86,11 @@ def enqueue_query(
queue = Queue(queue_name)
enqueue_kwargs = {
"job_id": f"org:{data_source.org_id}:user:{user_id}:id:{uuid4()}",
"user_id": user_id,
"scheduled_query_id": scheduled_query_id,
"is_api_key": is_api_key,
"job_timeout": time_limit,
"failure_ttl": settings.JOB_DEFAULT_FAILURE_TTL,
"meta": {
"is_query_execution": True,
"data_source_id": data_source.id,
"org_id": data_source.org_id,
"scheduled": scheduled_query_id is not None,

Some files were not shown because too many files have changed in this diff Show More