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_DISABLE_PIP_VERSION_CHECK=1
ENV PIP_NO_CACHE_DIR=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 # We first copy only the requirements file, to avoid rebuilding on every file
# change. # change.
COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./ 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 // allow debugger during development
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0, "no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
"jsx-a11y/anchor-is-valid": "off", "jsx-a11y/anchor-is-valid": "off",
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-restricted-imports": [ "no-restricted-imports": [
"error", "error",
{ {

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react"; 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 ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
import { Auth } from "@/services/auth"; import { Auth } from "@/services/auth";
import { policy } from "@/services/policy"; import { policy } from "@/services/policy";
@@ -61,10 +62,9 @@ export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserS
return ( return (
<ApplicationLayout> <ApplicationLayout>
<React.Fragment key={currentRoute.key}> <React.Fragment key={currentRoute.key}>
{/* @ts-expect-error FIXME */}
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}> <ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
<ErrorBoundaryContext.Consumer> <ErrorBoundaryContext.Consumer>
{({ handleError } /* : { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] } FIXME bring back type */) => {({ handleError }: { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] }) =>
render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError }) render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
} }
</ErrorBoundaryContext.Consumer> </ErrorBoundaryContext.Consumer>

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ export const TYPES = mapValues(
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"], NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"], GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"],
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"], 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"], ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
}, },
([url, title]) => [DOMAIN + HELP_PATH + url, title] ([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 @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 { ParameterMappingType } from "@/services/widget";
import { Parameter, cloneParameter } from "@/services/parameters"; import { Parameter, cloneParameter } from "@/services/parameters";
import HelpTrigger from "@/components/HelpTrigger"; import HelpTrigger from "@/components/HelpTrigger";
import InputPopover from "@/components/InputPopover";
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled"; import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
import EditOutlinedIcon from "@ant-design/icons/EditOutlined"; import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
@@ -313,43 +314,34 @@ class MappingEditor extends React.Component {
this.setState({ visible: false }); this.setState({ visible: false });
}; };
renderContent() {
const { mapping, inputError } = this.state;
return (
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
<header>
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
</header>
<ParameterMappingInput
mapping={mapping}
existingParamNames={this.props.existingParamNames}
onChange={this.onChange}
inputError={inputError}
/>
<footer>
<Button onClick={this.hide}>Cancel</Button>
<Button onClick={this.save} disabled={!!inputError} type="primary">
OK
</Button>
</footer>
</div>
);
}
render() { render() {
const { visible, mapping } = this.state; const { visible, mapping, inputError } = this.state;
return ( return (
<Popover <InputPopover
placement="left" placement="left"
trigger="click" trigger="click"
content={this.renderContent()} header={
<>
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
</>
}
content={
<ParameterMappingInput
mapping={mapping}
existingParamNames={this.props.existingParamNames}
onChange={this.onChange}
inputError={inputError}
/>
}
onOk={this.save}
onCancel={this.hide}
okButtonProps={{ disabled: !!inputError }}
visible={visible} visible={visible}
onVisibleChange={this.onVisibleChange}> onVisibleChange={this.onVisibleChange}>
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}> <Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
<EditOutlinedIcon /> <EditOutlinedIcon />
</Button> </Button>
</Popover> </InputPopover>
); );
} }
} }

View File

@@ -1,4 +1,4 @@
@import (reference, less) "~@/assets/less/ant"; // for ant @vars @import "~antd/lib/modal/style/index"; // for ant @vars
.parameters-mapping-list { .parameters-mapping-list {
.keyword { .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 { .parameter-mapping-title {
.text { .text {
margin-right: 3px; margin-right: 3px;

View File

@@ -101,6 +101,7 @@ class ParameterValueInput extends React.Component {
<SelectWithVirtualScroll <SelectWithVirtualScroll
className={this.props.className} className={this.props.className}
mode={parameter.multiValuesOptions ? "multiple" : "default"} mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children"
value={normalize(value)} value={normalize(value)}
onChange={this.onSelect} onChange={this.onSelect}
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))} options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
@@ -119,6 +120,7 @@ class ParameterValueInput extends React.Component {
<QueryBasedParameterInput <QueryBasedParameterInput
className={this.props.className} className={this.props.className}
mode={parameter.multiValuesOptions ? "multiple" : "default"} mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children"
parameter={parameter} parameter={parameter}
value={value} value={value}
queryId={queryId} 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; @input-dirty: #fffce1;
@@ -21,7 +21,7 @@
.@{ant-prefix}-input-number, .@{ant-prefix}-input-number,
.@{ant-prefix}-select-selector, .@{ant-prefix}-select-selector,
.@{ant-prefix}-picker { .@{ant-prefix}-picker {
background-color: @input-dirty; background-color: @input-dirty !important;
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,11 +35,11 @@ CounterCard.defaultProps = {
const queryJobsColumns = [ const queryJobsColumns = [
{ title: "Queue", dataIndex: "origin" }, { title: "Queue", dataIndex: "origin" },
{ title: "Query ID", dataIndex: ["meta", "query_id"] }, { title: "Query ID", dataIndex: "meta.query_id" },
{ title: "Org ID", dataIndex: ["meta", "org_id"] }, { title: "Org ID", dataIndex: "meta.org_id" },
{ title: "Data Source ID", dataIndex: ["meta", "data_source_id"] }, { title: "Data Source ID", dataIndex: "meta.data_source_id" },
{ title: "User ID", dataIndex: ["meta", "user_id"] }, { title: "User ID", dataIndex: "meta.user_id" },
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: ["meta", "scheduled"] }), Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: "meta.scheduled" }),
Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }), Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }),
Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_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 { .visual-card-list {
width: 100%; width: 100%;
@@ -6,7 +7,7 @@
} }
.visual-card { .visual-card {
background: #ffffff; background: #FFFFFF;
border: 1px solid fade(@redash-gray, 15%); border: 1px solid fade(@redash-gray, 15%);
border-radius: 3px; border-radius: 3px;
margin: 5px; margin: 5px;
@@ -73,4 +74,4 @@
height: 48px; height: 48px;
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ function getQueryResultData(queryResult, queryResultStatus = null) {
filters: invoke(queryResult, "getFilters") || [], filters: invoke(queryResult, "getFilters") || [],
updatedAt: invoke(queryResult, "getUpdatedAt") || null, updatedAt: invoke(queryResult, "getUpdatedAt") || null,
retrievedAt: get(queryResult, "query_result.retrieved_at", null), retrievedAt: get(queryResult, "query_result.retrieved_at", null),
truncated: invoke(queryResult, "getTruncated") || null,
log: invoke(queryResult, "getLog") || [], log: invoke(queryResult, "getLog") || [],
error: invoke(queryResult, "getError") || null, error: invoke(queryResult, "getError") || null,
runtime: invoke(queryResult, "getRuntime") || 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>
<Menu.Item> <Menu.Item>
<a onClick={confirmDelete}>Delete</a> <a onClick={confirmDelete}>Delete Alert</a>
</Menu.Item> </Menu.Item>
</Menu> </Menu>
}> }>

View File

@@ -30,13 +30,6 @@ const sidebarMenu = [
key: "all", key: "all",
href: "dashboards", href: "dashboards",
title: "All 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", key: "favorites",
@@ -164,7 +157,6 @@ const DashboardListPage = itemsList(
getResource({ params: { currentPage } }) { getResource({ params: { currentPage } }) {
return { return {
all: Dashboard.query.bind(Dashboard), all: Dashboard.query.bind(Dashboard),
my: Dashboard.myDashboards.bind(Dashboard),
favorites: Dashboard.favorites.bind(Dashboard), favorites: Dashboard.favorites.bind(Dashboard),
}[currentPage]; }[currentPage];
}, },
@@ -191,11 +183,3 @@ routes.register(
render: pageProps => <DashboardListPage {...pageProps} currentPage="favorites" />, 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 React, { useState, useEffect } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import cx from "classnames"; import cx from "classnames";
@@ -24,8 +24,8 @@ import DashboardHeader from "./components/DashboardHeader";
import "./DashboardPage.less"; import "./DashboardPage.less";
function DashboardSettings({ dashboardConfiguration }) { function DashboardSettings({ dashboardOptions }) {
const { dashboard, updateDashboard } = dashboardConfiguration; const { dashboard, updateDashboard } = dashboardOptions;
return ( return (
<div className="m-b-10 p-15 bg-white tiled"> <div className="m-b-10 p-15 bg-white tiled">
<Checkbox <Checkbox
@@ -39,11 +39,11 @@ function DashboardSettings({ dashboardConfiguration }) {
} }
DashboardSettings.propTypes = { 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 }) { function AddWidgetContainer({ dashboardOptions, className, ...props }) {
const { showAddTextboxDialog, showAddWidgetDialog } = dashboardConfiguration; const { showAddTextboxDialog, showAddWidgetDialog } = dashboardOptions;
return ( return (
<div className={cx("add-widget-container", className)} {...props}> <div className={cx("add-widget-container", className)} {...props}>
<h2> <h2>
@@ -66,12 +66,12 @@ function AddWidgetContainer({ dashboardConfiguration, className, ...props }) {
} }
AddWidgetContainer.propTypes = { 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, className: PropTypes.string,
}; };
function DashboardComponent(props) { function DashboardComponent(props) {
const dashboardConfiguration = useDashboard(props.dashboard); const dashboardOptions = useDashboard(props.dashboard);
const { const {
dashboard, dashboard,
filters, filters,
@@ -81,19 +81,14 @@ function DashboardComponent(props) {
removeWidget, removeWidget,
saveDashboardLayout, saveDashboardLayout,
globalParameters, globalParameters,
updateDashboard,
refreshDashboard, refreshDashboard,
refreshWidget, refreshWidget,
editingLayout, editingLayout,
setGridDisabled, setGridDisabled,
} = dashboardConfiguration; } = dashboardOptions;
const [pageContainer, setPageContainer] = useState(null); const [pageContainer, setPageContainer] = useState(null);
const [bottomPanelStyles, setBottomPanelStyles] = useState({}); const [bottomPanelStyles, setBottomPanelStyles] = useState({});
const onParametersEdit = parameters => {
const paramOrder = map(parameters, "name");
updateDashboard({ options: { globalParamOrder: paramOrder } });
};
useEffect(() => { useEffect(() => {
if (pageContainer) { if (pageContainer) {
@@ -119,23 +114,14 @@ function DashboardComponent(props) {
return ( return (
<div className="container" ref={setPageContainer} data-test={`DashboardId${dashboard.id}Container`}> <div className="container" ref={setPageContainer} data-test={`DashboardId${dashboard.id}Container`}>
<DashboardHeader <DashboardHeader
dashboardConfiguration={dashboardConfiguration} dashboardOptions={dashboardOptions}
headerExtra={ headerExtra={
<DynamicComponent <DynamicComponent name="Dashboard.HeaderExtra" dashboard={dashboard} dashboardOptions={dashboardOptions} />
name="Dashboard.HeaderExtra"
dashboard={dashboard}
dashboardConfiguration={dashboardConfiguration}
/>
} }
/> />
{!isEmpty(globalParameters) && ( {!isEmpty(globalParameters) && (
<div className="dashboard-parameters m-b-10 p-15 bg-white tiled" data-test="DashboardParameters"> <div className="dashboard-parameters m-b-10 p-15 bg-white tiled" data-test="DashboardParameters">
<Parameters <Parameters parameters={globalParameters} onValuesChange={refreshDashboard} />
parameters={globalParameters}
onValuesChange={refreshDashboard}
sortable={editingLayout}
onParametersEdit={onParametersEdit}
/>
</div> </div>
)} )}
{!isEmpty(filters) && ( {!isEmpty(filters) && (
@@ -143,7 +129,7 @@ function DashboardComponent(props) {
<Filters filters={filters} onChange={setFilters} /> <Filters filters={filters} onChange={setFilters} />
</div> </div>
)} )}
{editingLayout && <DashboardSettings dashboardConfiguration={dashboardConfiguration} />} {editingLayout && <DashboardSettings dashboardOptions={dashboardOptions} />}
<div id="dashboard-container"> <div id="dashboard-container">
<DashboardGrid <DashboardGrid
dashboard={dashboard} dashboard={dashboard}
@@ -158,9 +144,7 @@ function DashboardComponent(props) {
onParameterMappingsChange={loadDashboard} onParameterMappingsChange={loadDashboard}
/> />
</div> </div>
{editingLayout && ( {editingLayout && <AddWidgetContainer dashboardOptions={dashboardOptions} style={bottomPanelStyles} />}
<AddWidgetContainer dashboardConfiguration={dashboardConfiguration} style={bottomPanelStyles} />
)}
</div> </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 grid bg - based on 6 cols, 35px rows and 15px spacing

View File

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

View File

@@ -4,10 +4,6 @@ import BigMessage from "@/components/BigMessage";
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound"; import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState"; import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
import DynamicComponent from "@/components/DynamicComponent"; 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 { export interface DashboardListEmptyStateProps {
page: string; page: string;
@@ -25,20 +21,6 @@ export default function DashboardListEmptyState({ page, searchTerm, selectedTags
switch (page) { switch (page) {
case "favorites": case "favorites":
return <BigMessage message="Mark dashboards as Favorite to list them here." icon="fa-star" />; 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: default:
return ( return (
<DynamicComponent name="DashboardList.EmptyState"> <DynamicComponent name="DashboardList.EmptyState">

View File

@@ -33,13 +33,6 @@ const sidebarMenu = [
key: "all", key: "all",
href: "queries", href: "queries",
title: "All 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", key: "favorites",
@@ -47,6 +40,13 @@ const sidebarMenu = [
title: "Favorites", title: "Favorites",
icon: () => <Sidebar.MenuIcon icon="fa fa-star" />, 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", key: "archive",
href: "queries/archive", href: "queries/archive",

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import WarningTwoTone from "@ant-design/icons/WarningTwoTone";
import TimeAgo from "@/components/TimeAgo"; import TimeAgo from "@/components/TimeAgo";
import Tooltip from "antd/lib/tooltip";
import useAddToDashboardDialog from "../hooks/useAddToDashboardDialog"; import useAddToDashboardDialog from "../hooks/useAddToDashboardDialog";
import useEmbedDialog from "../hooks/useEmbedDialog"; import useEmbedDialog from "../hooks/useEmbedDialog";
import QueryControlDropdown from "@/components/EditVisualizationButton/QueryControlDropdown"; import QueryControlDropdown from "@/components/EditVisualizationButton/QueryControlDropdown";
@@ -44,18 +42,6 @@ export default function QueryExecutionMetadata({
)} )}
<span className="m-l-5 m-r-10"> <span className="m-l-5 m-r-10">
<span> <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)} <strong>{queryResultData.rows.length}</strong> {pluralize("row", queryResultData.rows.length)}
</span> </span>
<span className="m-l-5"> <span className="m-l-5">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,19 @@
@import "./variables"; @import "variables";
@font-face { @font-face {
font-family: "@{icomoon-font-family}"; 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');
src: url("@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm#iefix") format("embedded-opentype"), 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}.ttf?ehpufm') format('truetype'),
url("@{icomoon-font-path}/@{icomoon-font-family}.woff?ehpufm") format("woff"), 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"); url('@{icomoon-font-path}/@{icomoon-font-family}.svg?ehpufm#@{icomoon-font-family}') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
i.icon { i.icon {
/* use !important to prevent issues with browser extensions that change fonts */ /* 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; speak: none;
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;
@@ -28,11 +28,12 @@ i.icon {
.icon-flash-off { .icon-flash-off {
&:before { &:before {
content: @icon-flash-off; content: @icon-flash-off;
} }
} }
.icon-flash { .icon-flash {
&:before { &:before {
content: @icon-flash; content: @icon-flash;
} }
} }

View File

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

View File

@@ -168,7 +168,6 @@ const DashboardService = {
delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse), delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse),
query: params => axios.get("api/dashboards", { params }).then(transformResponse), query: params => axios.get("api/dashboards", { params }).then(transformResponse),
recent: params => axios.get("api/dashboards/recent", { 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), favorites: params => axios.get("api/dashboards/favorites", { params }).then(transformResponse),
favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`), favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`),
unfavorite: ({ id }) => axios.delete(`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 => { _.each(globalParams, param => {
param.setValue(param.value); // apply global param value to all locals param.setValue(param.value); // apply global param value to all locals
param.fromUrlParams(queryParams); // try to initialize from url (may do nothing) param.fromUrlParams(queryParams); // try to initialize from url (may do nothing)
}) })
); );
// 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 = {}) { 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 { isNull, isObject, isFunction, isUndefined, isEqual, has, omit, isArray, each } from "lodash";
import { toHuman } from "@/lib/utils";
class Parameter { class Parameter {
constructor(parameter, parentQueryId) { constructor(parameter, parentQueryId) {
@@ -44,6 +45,10 @@ class Parameter {
return this.$$value; return this.$$value;
} }
getTitle() {
return this.title || toHuman(this.name);
}
isEmptyValue(value) { isEmptyValue(value) {
return isNull(this.normalizeValue(value)); return isNull(this.normalizeValue(value));
} }

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,6 @@
import moment from "moment"; import moment from "moment";
import { axios } from "@/services/axios"; import { axios } from "@/services/axios";
import { import { each, pick, extend, isObject, truncate, keys, difference, filter, map, merge } from "lodash";
each,
pick,
extend,
isObject,
truncate,
keys,
difference,
filter,
map,
merge,
sortBy,
indexOf,
size,
includes,
} from "lodash";
import location from "@/services/location"; import location from "@/services/location";
import { cloneParameter } from "@/services/parameters"; import { cloneParameter } from "@/services/parameters";
import dashboardGridOptions from "@/config/dashboard-grid-options"; import dashboardGridOptions from "@/config/dashboard-grid-options";
@@ -222,7 +207,7 @@ class Widget {
const queryParams = location.search; const queryParams = location.search;
const localTypes = [Widget.MappingType.WidgetLevel, Widget.MappingType.StaticValue]; const localTypes = [Widget.MappingType.WidgetLevel, Widget.MappingType.StaticValue];
const localParameters = map( return map(
filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0), filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0),
param => { param => {
const mapping = mappings[param.name]; const mapping = mappings[param.name];
@@ -238,13 +223,6 @@ class Widget {
return result; 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() { 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", () => { describe("Parameter", () => {
const expectDirtyStateChange = edit => { const expectDirtyStateChange = edit => {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter")
@@ -115,14 +107,12 @@ describe("Parameter", () => {
}); });
it("updates the results after selecting a value", () => { it("updates the results after selecting a value", () => {
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop cy.getByTestId("ParameterName-test-parameter")
.find(".ant-select")
// only the filtered option should be on the DOM
cy.get(".ant-select-item-option")
.should("have.length", 1)
.and("contain", "value2")
.click(); .click();
cy.contains(".ant-select-item-option", "value2").click();
cy.getByTestId("ParameterApplyButton").click(); cy.getByTestId("ParameterApplyButton").click();
// ensure that query is being executed // ensure that query is being executed
cy.getByTestId("QueryExecutionStatus").should("exist"); 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", () => { it("supports multi-selection", () => {
cy.clickThrough(` cy.clickThrough(`
ParameterSettings-test-parameter ParameterSettings-test-parameter
@@ -601,6 +575,16 @@ describe("Parameter", () => {
cy.get("body").type("{alt}D"); // hide schema browser 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() { it("is possible to rearrange parameters", function() {
cy.server(); cy.server();
cy.route("POST", "**/api/queries/*").as("QuerySave"); cy.route("POST", "**/api/queries/*").as("QuerySave");

View File

@@ -6,40 +6,10 @@ describe("Settings", () => {
it("renders the page and takes a screenshot", () => { it("renders the page and takes a screenshot", () => {
cy.getByTestId("OrganizationSettings").within(() => { cy.getByTestId("OrganizationSettings").within(() => {
cy.getByTestId("DateFormatSelect").should("contain", "DD/MM/YY");
cy.getByTestId("TimeFormatSelect").should("contain", "HH:mm"); cy.getByTestId("TimeFormatSelect").should("contain", "HH:mm");
}); });
cy.percySnapshot("Organization Settings"); 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 "@percy/cypress"; // eslint-disable-line import/no-extraneous-dependencies, import/no-unresolved
import "@testing-library/cypress/add-commands";
const { each } = Cypress._; const { each } = Cypress._;
Cypress.Commands.add("login", (email = "admin@redash.io", password = "password") => { 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", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"types": ["cypress", "@percy/cypress", "@testing-library/cypress"] "types": ["cypress","@percy/cypress","@testing-library/cypress"]
}, },
"include": ["./**/*.ts"] "include": ["./**/*.ts"]
} }

View File

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

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

View File

@@ -9,7 +9,7 @@ from sqlalchemy.sql import select
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
from redash import settings 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.models.types import EncryptedConfiguration
from redash.utils.configuration import ConfigurationContainer from redash.utils.configuration import ConfigurationContainer
@@ -27,7 +27,7 @@ def _wait_for_db_connection(db):
retried = True retried = True
def is_db_empty(): def is_db_empty():
from redash.models import db from redash.models import db
@@ -86,40 +86,36 @@ def reencrypt(old_secret, new_secret, show_sql):
logging.basicConfig() logging.basicConfig()
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
def _reencrypt_for_table(table_name, orm_name): table_for_select = sqlalchemy.Table(
table_for_select = sqlalchemy.Table( "data_sources",
table_name, sqlalchemy.MetaData(),
sqlalchemy.MetaData(), Column("id", db.Integer, primary_key=True),
Column("id", key_type(orm_name), primary_key=True), Column(
Column( "encrypted_options",
"encrypted_options", ConfigurationContainer.as_mutable(
ConfigurationContainer.as_mutable( EncryptedConfiguration(db.Text, old_secret, FernetEngine)
EncryptedConfiguration(db.Text, old_secret, FernetEngine)
),
), ),
) ),
table_for_update = sqlalchemy.Table( )
table_name, table_for_update = sqlalchemy.Table(
sqlalchemy.MetaData(), "data_sources",
Column("id", key_type(orm_name), primary_key=True), sqlalchemy.MetaData(),
Column( Column("id", db.Integer, primary_key=True),
"encrypted_options", Column(
ConfigurationContainer.as_mutable( "encrypted_options",
EncryptedConfiguration(db.Text, new_secret, FernetEngine) ConfigurationContainer.as_mutable(
), EncryptedConfiguration(db.Text, new_secret, FernetEngine)
), ),
),
)
update = table_for_update.update()
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)
update = table_for_update.update() data_sources.close()
selected_items = db.session.execute(select([table_for_select])) db.session.commit()
for item in selected_items:
stmt = update.where(table_for_update.c.id == item["id"]).values(
encrypted_options=item["encrypted_options"]
)
db.session.execute(stmt)
selected_items.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): class WorkerHealthcheck(base.BaseCheck):
NAME = "RQ Worker Healthcheck" NAME = 'RQ Worker Healthcheck'
INTERVAL = datetime.timedelta(minutes=5)
_last_check_time = {}
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
return False
def __call__(self, process_spec): def __call__(self, process_spec):
pid = process_spec["pid"] pid = process_spec['pid']
if not self.time_to_check(pid):
return True
all_workers = Worker.all(connection=rq_redis_connection) all_workers = Worker.all(connection=rq_redis_connection)
workers = [ worker = [w for w in all_workers if w.hostname == socket.gethostname().encode() and
w w.pid == pid].pop()
for w in all_workers
if w.hostname == socket.gethostname() and w.pid == pid
]
if not workers:
self._log(f"Cannot find worker for hostname {socket.gethostname()} and pid {pid}. ==> Is healthy? False")
return False
worker = workers.pop()
is_busy = worker.get_state() == WorkerStatus.BUSY 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 is_healthy = is_busy or seen_lately or has_nothing_to_do
self._log( self._log("Worker %s healthcheck: Is busy? %s. "
"Worker %s healthcheck: Is busy? %s. " "Seen lately? %s (%d seconds ago). "
"Seen lately? %s (%d seconds ago). " "Has nothing to do? %s (%d jobs in watched queues). "
"Has nothing to do? %s (%d jobs in watched queues). " "==> Is healthy? %s",
"==> Is healthy? %s", worker.key, is_busy, seen_lately, time_since_seen.seconds,
worker.key, has_nothing_to_do, total_jobs_in_watched_queues, is_healthy)
is_busy,
seen_lately,
time_since_seen.seconds,
has_nothing_to_do,
total_jobs_in_watched_queues,
is_healthy,
)
return is_healthy return is_healthy
@@ -97,5 +98,4 @@ class WorkerHealthcheck(base.BaseCheck):
@manager.command() @manager.command()
def healthcheck(): def healthcheck():
return check_runner.CheckRunner( return check_runner.CheckRunner(
"worker_healthcheck", "worker", None, [(WorkerHealthcheck, {})] 'worker_healthcheck', 'worker', None, [(WorkerHealthcheck, {})]).run()
).run()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -113,43 +113,6 @@ class DashboardListResource(BaseResource):
return DashboardSerializer(dashboard).serialize() 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): class DashboardResource(BaseResource):
@require_permission("list_dashboards") @require_permission("list_dashboards")
def get(self, dashboard_id=None): 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 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 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 array widgets: Array of arrays containing :ref:`widget <widget-response-label>` data
:>json object options: Dashboard options
.. _widget-response-label: .. _widget-response-label:
@@ -243,7 +205,6 @@ class DashboardResource(BaseResource):
"is_draft", "is_draft",
"is_archived", "is_archived",
"dashboard_filters_enabled", "dashboard_filters_enabled",
"options",
), ),
) )

View File

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

View File

@@ -1099,9 +1099,6 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
tags = Column( tags = Column(
"tags", MutableList.as_mutable(postgresql.ARRAY(db.Unicode)), nullable=True "tags", MutableList.as_mutable(postgresql.ARRAY(db.Unicode)), nullable=True
) )
options = Column(
MutableDict.as_mutable(postgresql.JSON), server_default="{}", default={}
)
__tablename__ = "dashboards" __tablename__ = "dashboards"
__mapper_args__ = {"version_id_col": version} __mapper_args__ = {"version_id_col": version}
@@ -1135,6 +1132,7 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
), ),
Dashboard.org == org, Dashboard.org == org,
) )
.distinct()
) )
query = query.filter( query = query.filter(
@@ -1150,10 +1148,6 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
cls.name.ilike("%{}%".format(search_term)) 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 @classmethod
def all_tags(cls, org, user): def all_tags(cls, org, user):
dashboards = cls.all(org, user.group_ids, user.id) 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) ).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 @classmethod
def get_by_slug_and_org(cls, slug, org): def get_by_slug_and_org(cls, slug, org):
return cls.query.filter(cls.slug == slug, cls.org == org).one() 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") user = db.relationship(User, backref="notification_destinations")
name = Column(db.String(255)) name = Column(db.String(255))
type = Column(db.String(255)) type = Column(db.String(255))
options = Column( options = Column(ConfigurationContainer.as_mutable(Configuration))
"encrypted_options",
ConfigurationContainer.as_mutable(
EncryptedConfiguration(
db.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine
)
),
)
created_at = Column(db.DateTime(True), default=db.func.now()) created_at = Column(db.DateTime(True), default=db.func.now())
__tablename__ = "notification_destinations" __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 redash.permissions import require_access, view_only
from funcy import distinct from funcy import distinct
from dateutil.parser import parse from dateutil.parser import parse
from redash import models
def _pluck_name_and_value(default_column, row): def _pluck_name_and_value(default_column, row):
@@ -15,22 +16,18 @@ def _pluck_name_and_value(default_column, row):
return {"name": row[name_column], "value": str(row[value_column])} return {"name": row[name_column], "value": str(row[value_column])}
def _load_result(query_id, org): def _load_result(query, org):
from redash import models
query = models.Query.get_by_id_and_org(query_id, org)
if query.data_source: if query.data_source:
query_result = models.QueryResult.get_by_id_and_org( query_result = models.QueryResult.get_by_id_and_org(
query.latest_query_data_id, org query.latest_query_data_id, org
) )
return query_result.data return query_result.data
else: else:
raise QueryDetachedFromDataSourceError(query_id) raise QueryDetachedFromDataSourceError(query.id)
def dropdown_values(query_id, org): def dropdown_values(query, org):
data = _load_result(query_id, org) data = _load_result(query, org)
first_column = data["columns"][0]["name"] first_column = data["columns"][0]["name"]
pluck = partial(_pluck_name_and_value, first_column) pluck = partial(_pluck_name_and_value, first_column)
return list(map(pluck, data["rows"])) return list(map(pluck, data["rows"]))
@@ -155,6 +152,12 @@ class ParameterizedQuery(object):
query_id = definition.get("queryId") query_id = definition.get("queryId")
allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict) allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict)
if definition["type"] == "query":
try:
query = models.Query.get_by_id_and_org(query_id, self.org)
except (models.NoResultFound):
return False
if isinstance(enum_options, str): if isinstance(enum_options, str):
enum_options = enum_options.split("\n") enum_options = enum_options.split("\n")
@@ -166,9 +169,11 @@ class ParameterizedQuery(object):
), ),
"query": lambda value: _is_value_within_options( "query": lambda value: _is_value_within_options(
value, value,
[v["value"] for v in dropdown_values(query_id, self.org)], [v["value"] for v in dropdown_values(query, self.org)],
allow_multiple_values, allow_multiple_values,
), )
if not query.parameters
else True,
"date": _is_date, "date": _is_date,
"datetime-local": _is_date, "datetime-local": _is_date,
"datetime-with-seconds": _is_date, "datetime-with-seconds": _is_date,
@@ -183,8 +188,18 @@ class ParameterizedQuery(object):
@property @property
def is_safe(self): def is_safe(self):
text_parameters = [param for param in self.schema if param["type"] == "text"] for param in self.schema:
return not any(text_parameters) if param["type"] == "text":
return False
if param["type"] == "query":
try:
query = models.Query.get_by_id_and_org(
param.get("queryId"), self.org
)
return not query.parameters
except (models.NoResultFound):
return True
return True
@property @property
def missing_params(self): def missing_params(self):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ from .helpers import (
int_or_none, int_or_none,
set_from_string, set_from_string,
add_decode_responses_to_redis_url, add_decode_responses_to_redis_url,
cast_int_or_default
) )
from .organization import DATE_FORMAT, TIME_FORMAT # noqa 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") THROTTLE_LOGIN_PATTERN = os.environ.get("REDASH_THROTTLE_LOGIN_PATTERN", "50/hour")
LIMITER_STORAGE = os.environ.get("REDASH_LIMITER_STORAGE", REDIS_URL) 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 # 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). # to the calling domain (or domains in a comma separated list).
ACCESS_CONTROL_ALLOW_ORIGIN = set_from_string( ACCESS_CONTROL_ALLOW_ORIGIN = set_from_string(
@@ -512,6 +511,4 @@ ENFORCE_CSRF = parse_boolean(
os.environ.get("REDASH_ENFORCE_CSRF", "false") os.environ.get("REDASH_ENFORCE_CSRF", "false")
) )
# Databricks
CSRF_TIME_LIMIT = int(os.environ.get("REDASH_CSRF_TIME_LIMIT", 3600 * 6)) CSRF_TIME_LIMIT = int(os.environ.get("REDASH_CSRF_TIME_LIMIT", 3600 * 6))

View File

@@ -60,15 +60,4 @@ 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. # 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. # To do so, simply add the name of the extension you'd like to load to this list.
database_extensions = [] 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: else:
raise ValueError("Invalid boolean value %r" % s) 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): def int_or_none(value):
if value is None: if value is None:

View File

@@ -3,6 +3,7 @@ from .general import (
version_check, version_check,
send_mail, send_mail,
sync_user_details, sync_user_details,
purge_failed_jobs,
) )
from .queries import ( from .queries import (
enqueue_query, enqueue_query,
@@ -16,7 +17,6 @@ from .queries import (
from .alerts import check_alerts_for_query from .alerts import check_alerts_for_query
from .failure_report import send_aggregated_errors from .failure_report import send_aggregated_errors
from .worker import Worker, Queue, Job from .worker import Worker, Queue, Job
from .capacity import cleanup_waiting_lists
from .schedule import rq_scheduler, schedule_periodic_jobs, periodic_job_definitions from .schedule import rq_scheduler, schedule_periodic_jobs, periodic_job_definitions
from redash import rq_redis_connection 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 datetime import datetime
from flask_mail import Message 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.models import users
from redash.version_check import run_version_check 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.tasks.worker import Queue
from redash.query_runner import NotSupported from redash.query_runner import NotSupported
@@ -91,3 +94,35 @@ def get_schema(data_source_id, refresh):
def sync_user_details(): def sync_user_details():
users.sync_last_active_at() 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 signal
import time import time
import redis import redis
from uuid import uuid4
from rq import get_current_job from rq import get_current_job
from rq.job import JobStatus from rq.job import JobStatus
@@ -87,14 +86,11 @@ def enqueue_query(
queue = Queue(queue_name) queue = Queue(queue_name)
enqueue_kwargs = { enqueue_kwargs = {
"job_id": f"org:{data_source.org_id}:user:{user_id}:id:{uuid4()}",
"user_id": user_id, "user_id": user_id,
"scheduled_query_id": scheduled_query_id, "scheduled_query_id": scheduled_query_id,
"is_api_key": is_api_key, "is_api_key": is_api_key,
"job_timeout": time_limit, "job_timeout": time_limit,
"failure_ttl": settings.JOB_DEFAULT_FAILURE_TTL,
"meta": { "meta": {
"is_query_execution": True,
"data_source_id": data_source.id, "data_source_id": data_source.id,
"org_id": data_source.org_id, "org_id": data_source.org_id,
"scheduled": scheduled_query_id is not None, "scheduled": scheduled_query_id is not None,
@@ -252,7 +248,7 @@ class QueryExecutor(object):
def _log_progress(self, state): def _log_progress(self, state):
logger.info( logger.info(
"job=execute_query state=%s query_hash=%s type=%s ds_id=%d " "job=execute_query state=%s query_hash=%s type=%s ds_id=%d "
"job_id=%s queue=%s query_id=%s username=%s", "job_id=%s queue=%s query_id=%s username=%s",
state, state,
self.query_hash, self.query_hash,

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