mirror of
https://github.com/getredash/redash.git
synced 2025-12-20 09:57:35 -05:00
Compare commits
35 Commits
query-base
...
user-and-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dce31dd32 | ||
|
|
46e97a08cc | ||
|
|
640fea5e47 | ||
|
|
c865293aaa | ||
|
|
3d3f6b1916 | ||
|
|
0e1587a068 | ||
|
|
04edf16ed4 | ||
|
|
49536de1ed | ||
|
|
2f1394a6f4 | ||
|
|
911f398006 | ||
|
|
b0b1d6c81c | ||
|
|
23a279f318 | ||
|
|
e71ccf5de5 | ||
|
|
bb42e92cd0 | ||
|
|
4ec96caac5 | ||
|
|
829247c2d2 | ||
|
|
7d33af4343 | ||
|
|
84c2abed59 | ||
|
|
8b068dfd0b | ||
|
|
06eb868120 | ||
|
|
52ae7bedb2 | ||
|
|
fbe57de53c | ||
|
|
db0cb98ed3 | ||
|
|
dcdff66e62 | ||
|
|
d0793c4ba8 | ||
|
|
7b8bcdf356 | ||
|
|
c290864ccd | ||
|
|
b70e95a323 | ||
|
|
18ee5343aa | ||
|
|
fdf636a393 | ||
|
|
88c13868a3 | ||
|
|
aab11dc79b | ||
|
|
00c77cf36e | ||
|
|
6e2631dec2 | ||
|
|
4b88959341 |
@@ -79,6 +79,9 @@ WORKDIR /app
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV PIP_NO_CACHE_DIR=1
|
||||
|
||||
# Use legacy resolver to work around broken build due to resolver changes in pip
|
||||
ENV PIP_USE_DEPRECATED=legacy-resolver
|
||||
|
||||
# We first copy only the requirements file, to avoid rebuilding on every file
|
||||
# change.
|
||||
COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./
|
||||
|
||||
@@ -20,6 +20,7 @@ module.exports = {
|
||||
// allow debugger during development
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
||||
"jsx-a11y/anchor-is-valid": "off",
|
||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { first } from "lodash";
|
||||
import React, { useState } from "react";
|
||||
import Button from "antd/lib/button";
|
||||
import React, { useMemo } from "react";
|
||||
import { first, includes } from "lodash";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Link from "@/components/Link";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||
import { useCurrentRoute } from "@/components/ApplicationArea/Router";
|
||||
import { Auth, currentUser } from "@/services/auth";
|
||||
import settingsMenu from "@/services/settingsMenu";
|
||||
import logoUrl from "@/assets/images/redash_icon_small.png";
|
||||
@@ -15,37 +15,64 @@ import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
|
||||
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
|
||||
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
|
||||
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
|
||||
import MenuUnfoldOutlinedIcon from "@ant-design/icons/MenuUnfoldOutlined";
|
||||
import MenuFoldOutlinedIcon from "@ant-design/icons/MenuFoldOutlined";
|
||||
|
||||
import VersionInfo from "./VersionInfo";
|
||||
import "./DesktopNavbar.less";
|
||||
|
||||
function NavbarSection({ inlineCollapsed, children, ...props }) {
|
||||
function NavbarSection({ children, ...props }) {
|
||||
return (
|
||||
<Menu
|
||||
selectable={false}
|
||||
mode={inlineCollapsed ? "inline" : "vertical"}
|
||||
inlineCollapsed={inlineCollapsed}
|
||||
theme="dark"
|
||||
{...props}>
|
||||
<Menu selectable={false} mode="vertical" theme="dark" {...props}>
|
||||
{children}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DesktopNavbar() {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
function useNavbarActiveState() {
|
||||
const currentRoute = useCurrentRoute();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
dashboards: includes(
|
||||
[
|
||||
"Dashboards.List",
|
||||
"Dashboards.Favorites",
|
||||
"Dashboards.My",
|
||||
"Dashboards.ViewOrEdit",
|
||||
"Dashboards.LegacyViewOrEdit",
|
||||
],
|
||||
currentRoute.id
|
||||
),
|
||||
queries: includes(
|
||||
[
|
||||
"Queries.List",
|
||||
"Queries.Favorites",
|
||||
"Queries.Archived",
|
||||
"Queries.My",
|
||||
"Queries.View",
|
||||
"Queries.New",
|
||||
"Queries.Edit",
|
||||
],
|
||||
currentRoute.id
|
||||
),
|
||||
dataSources: includes(["DataSources.List"], currentRoute.id),
|
||||
alerts: includes(["Alerts.List", "Alerts.New", "Alerts.View", "Alerts.Edit"], currentRoute.id),
|
||||
}),
|
||||
[currentRoute.id]
|
||||
);
|
||||
}
|
||||
|
||||
export default function DesktopNavbar() {
|
||||
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
||||
|
||||
const activeState = useNavbarActiveState();
|
||||
|
||||
const canCreateQuery = currentUser.hasPermission("create_query");
|
||||
const canCreateDashboard = currentUser.hasPermission("create_dashboard");
|
||||
const canCreateAlert = currentUser.hasPermission("list_alerts");
|
||||
|
||||
return (
|
||||
<div className="desktop-navbar">
|
||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo">
|
||||
<NavbarSection className="desktop-navbar-logo">
|
||||
<div>
|
||||
<Link href="./">
|
||||
<img src={logoUrl} alt="Redash" />
|
||||
@@ -53,45 +80,43 @@ export default function DesktopNavbar() {
|
||||
</div>
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed}>
|
||||
<NavbarSection>
|
||||
{currentUser.hasPermission("list_dashboards") && (
|
||||
<Menu.Item key="dashboards">
|
||||
<Menu.Item key="dashboards" className={activeState.dashboards ? "navbar-active-item" : null}>
|
||||
<Link href="dashboards">
|
||||
<DesktopOutlinedIcon />
|
||||
<span>Dashboards</span>
|
||||
<span className="desktop-navbar-label">Dashboards</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("view_query") && (
|
||||
<Menu.Item key="queries">
|
||||
<Menu.Item key="queries" className={activeState.queries ? "navbar-active-item" : null}>
|
||||
<Link href="queries">
|
||||
<CodeOutlinedIcon />
|
||||
<span>Queries</span>
|
||||
<span className="desktop-navbar-label">Queries</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("list_alerts") && (
|
||||
<Menu.Item key="alerts">
|
||||
<Menu.Item key="alerts" className={activeState.alerts ? "navbar-active-item" : null}>
|
||||
<Link href="alerts">
|
||||
<AlertOutlinedIcon />
|
||||
<span>Alerts</span>
|
||||
<span className="desktop-navbar-label">Alerts</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-spacer">
|
||||
{(canCreateQuery || canCreateDashboard || canCreateAlert) && <Menu.Divider />}
|
||||
<NavbarSection className="desktop-navbar-spacer">
|
||||
{(canCreateQuery || canCreateDashboard || canCreateAlert) && (
|
||||
<Menu.SubMenu
|
||||
key="create"
|
||||
popupClassName="desktop-navbar-submenu"
|
||||
data-test="CreateButton"
|
||||
title={
|
||||
<React.Fragment>
|
||||
<span data-test="CreateButton">
|
||||
<PlusOutlinedIcon />
|
||||
<span>Create</span>
|
||||
</span>
|
||||
<span className="desktop-navbar-label">Create</span>
|
||||
</React.Fragment>
|
||||
}>
|
||||
{canCreateQuery && (
|
||||
@@ -119,32 +144,30 @@ export default function DesktopNavbar() {
|
||||
)}
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed}>
|
||||
<NavbarSection>
|
||||
<Menu.Item key="help">
|
||||
<HelpTrigger showTooltip={false} type="HOME">
|
||||
<QuestionCircleOutlinedIcon />
|
||||
<span>Help</span>
|
||||
<span className="desktop-navbar-label">Help</span>
|
||||
</HelpTrigger>
|
||||
</Menu.Item>
|
||||
{firstSettingsTab && (
|
||||
<Menu.Item key="settings">
|
||||
<Menu.Item key="settings" className={activeState.dataSources ? "navbar-active-item" : null}>
|
||||
<Link href={firstSettingsTab.path} data-test="SettingsLink">
|
||||
<SettingOutlinedIcon />
|
||||
<span>Settings</span>
|
||||
<span className="desktop-navbar-label">Settings</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-profile-menu">
|
||||
<NavbarSection className="desktop-navbar-profile-menu">
|
||||
<Menu.SubMenu
|
||||
key="profile"
|
||||
popupClassName="desktop-navbar-submenu"
|
||||
title={
|
||||
<span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title">
|
||||
<img className="profile__image_thumb" src={currentUser.profile_image_url} alt={currentUser.name} />
|
||||
<span>{currentUser.name}</span>
|
||||
</span>
|
||||
}>
|
||||
<Menu.Item key="profile">
|
||||
@@ -167,10 +190,6 @@ export default function DesktopNavbar() {
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
</NavbarSection>
|
||||
|
||||
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
|
||||
{collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
@backgroundColor: #001529;
|
||||
@dividerColor: rgba(255, 255, 255, 0.5);
|
||||
@textColor: rgba(255, 255, 255, 0.75);
|
||||
@brandColor: #ff7964; // Redash logo color
|
||||
@activeItemColor: @brandColor;
|
||||
@iconSize: 26px;
|
||||
|
||||
.desktop-navbar {
|
||||
background: @backgroundColor;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 80px;
|
||||
overflow: hidden;
|
||||
|
||||
&-spacer {
|
||||
flex: 1 1 auto;
|
||||
@@ -21,12 +26,6 @@
|
||||
height: 40px;
|
||||
transition: all 270ms;
|
||||
}
|
||||
|
||||
&.ant-menu-inline-collapsed {
|
||||
img {
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.help-trigger {
|
||||
@@ -34,26 +33,19 @@
|
||||
}
|
||||
|
||||
.ant-menu {
|
||||
&:not(.ant-menu-inline-collapsed) {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
&.ant-menu-inline-collapsed > .ant-menu-submenu-title span img + span,
|
||||
&.ant-menu-inline-collapsed > .ant-menu-item i + span {
|
||||
display: inline-block;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ant-menu-item-divider {
|
||||
background: @dividerColor;
|
||||
}
|
||||
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu {
|
||||
font-weight: 500;
|
||||
color: @textColor;
|
||||
|
||||
&.navbar-active-item {
|
||||
box-shadow: inset 3px 0 0 @activeItemColor;
|
||||
|
||||
.anticon {
|
||||
color: @activeItemColor;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-submenu-open,
|
||||
&.ant-menu-submenu-active,
|
||||
&:hover,
|
||||
@@ -61,6 +53,16 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: @iconSize;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.desktop-navbar-label {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
a,
|
||||
span,
|
||||
.anticon {
|
||||
@@ -71,21 +73,33 @@
|
||||
.ant-menu-submenu-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu {
|
||||
padding: 0;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ant-btn.desktop-navbar-collapse-button {
|
||||
background-color: @backgroundColor;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: @textColor;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
color: #fff;
|
||||
.ant-menu-submenu-title {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&:after {
|
||||
animation: 0s !important;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,37 +113,8 @@
|
||||
.profile__image_thumb {
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
width: @iconSize;
|
||||
height: @iconSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
// @ts-expect-error (Must be removed after adding @redash/viz typing)
|
||||
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||
import { Auth } from "@/services/auth";
|
||||
import { policy } from "@/services/policy";
|
||||
@@ -62,9 +61,10 @@ export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserS
|
||||
return (
|
||||
<ApplicationLayout>
|
||||
<React.Fragment key={currentRoute.key}>
|
||||
{/* @ts-expect-error FIXME */}
|
||||
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
|
||||
<ErrorBoundaryContext.Consumer>
|
||||
{({ handleError }: { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] }) =>
|
||||
{({ handleError } /* : { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] } FIXME bring back type */) =>
|
||||
render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
|
||||
}
|
||||
</ErrorBoundaryContext.Consumer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '~antd/lib/button/style/index';
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
.code-block {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { includes, words, capitalize, clone, isNull, map, get, find } from "lodash";
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { includes, words, capitalize, clone, isNull } from "lodash";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Checkbox from "antd/lib/checkbox";
|
||||
import Modal from "antd/lib/modal";
|
||||
@@ -11,8 +11,6 @@ import Divider from "antd/lib/divider";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import QuerySelector from "@/components/QuerySelector";
|
||||
import { Query } from "@/services/query";
|
||||
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||
import QueryBasedParameterMappingTable from "./query-based-parameter/QueryBasedParameterMappingTable";
|
||||
|
||||
const { Option } = Select;
|
||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||
@@ -71,27 +69,17 @@ NameInput.propTypes = {
|
||||
function EditParameterSettingsDialog(props) {
|
||||
const [param, setParam] = useState(clone(props.parameter));
|
||||
const [isNameValid, setIsNameValid] = useState(true);
|
||||
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 [initialQuery, setInitialQuery] = useState();
|
||||
|
||||
const isNew = !props.parameter.name;
|
||||
|
||||
// fetch query by id
|
||||
const initialQueryId = useRef(props.parameter.queryId);
|
||||
useEffect(() => {
|
||||
if (initialQueryId.current) {
|
||||
Query.get({ id: initialQueryId.current }).then(setParamQuery);
|
||||
const queryId = props.parameter.queryId;
|
||||
if (queryId) {
|
||||
Query.get({ id: queryId }).then(setInitialQuery);
|
||||
}
|
||||
}, []);
|
||||
}, [props.parameter.queryId]);
|
||||
|
||||
function isFulfilled() {
|
||||
// name
|
||||
@@ -105,16 +93,10 @@ function EditParameterSettingsDialog(props) {
|
||||
}
|
||||
|
||||
// query
|
||||
if (param.type === "query") {
|
||||
if (!param.queryId) {
|
||||
if (param.type === "query" && !param.queryId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (find(mappingParameters, { existingMapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED } })) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -205,28 +187,14 @@ function EditParameterSettingsDialog(props) {
|
||||
</Form.Item>
|
||||
)}
|
||||
{param.type === "query" && (
|
||||
<Form.Item label="Query" help="Select query to load dropdown values from" required {...formItemProps}>
|
||||
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
|
||||
<QuerySelector
|
||||
selectedQuery={paramQuery}
|
||||
onChange={q => {
|
||||
if (q) {
|
||||
setParamQuery(q);
|
||||
setParam({ ...param, queryId: q.id, parameterMapping: {} });
|
||||
}
|
||||
}}
|
||||
selectedQuery={initialQuery}
|
||||
onChange={q => setParam({ ...param, queryId: q && q.id })}
|
||||
type="select"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{param.type === "query" && paramQuery && paramQuery.hasParameters() && (
|
||||
<Form.Item className="m-t-15 m-b-5" label="Parameters" required {...formItemProps}>
|
||||
<QueryBasedParameterMappingTable
|
||||
param={param}
|
||||
mappingParameters={mappingParameters}
|
||||
onChangeParam={setParam}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{(param.type === "enum" || param.type === "query") && (
|
||||
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||
<Checkbox
|
||||
@@ -1,134 +0,0 @@
|
||||
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: () => {},
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
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: () => {},
|
||||
};
|
||||
@@ -45,7 +45,7 @@ export const TYPES = mapValues(
|
||||
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
|
||||
GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"],
|
||||
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"],
|
||||
QUERIES: ["/help/user-guide/querying", "Guide: Queries"],
|
||||
QUERIES: ["/user-guide/querying", "Guide: Queries"],
|
||||
ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
|
||||
},
|
||||
([url, title]) => [DOMAIN + HELP_PATH + url, title]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~antd/lib/drawer/style/drawer";
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
@help-doc-bg: #f7f7f7; // according to https://github.com/getredash/website/blob/13daff2d8b570956565f482236f6245042e8477f/src/scss/_components/_variables.scss#L15
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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: () => {},
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
@import "~antd/lib/modal/style/index"; // for ant @vars
|
||||
|
||||
.input-popover-content {
|
||||
width: 390px;
|
||||
|
||||
.radio {
|
||||
display: block;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 0 16px 10px;
|
||||
margin: 0 -16px 20px;
|
||||
border-bottom: @border-width-base @border-style-base @border-color-split;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 500;
|
||||
color: @heading-color;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: @border-width-base @border-style-base @border-color-split;
|
||||
padding: 10px 16px 0;
|
||||
margin: 0 -16px;
|
||||
text-align: right;
|
||||
|
||||
button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import ParameterValueInput from "@/components/ParameterValueInput";
|
||||
import { ParameterMappingType } from "@/services/widget";
|
||||
import { Parameter, cloneParameter } from "@/services/parameters";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import InputPopover from "@/components/InputPopover";
|
||||
|
||||
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
||||
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
||||
@@ -314,34 +313,43 @@ class MappingEditor extends React.Component {
|
||||
this.setState({ visible: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { visible, mapping, inputError } = this.state;
|
||||
renderContent() {
|
||||
const { mapping, inputError } = this.state;
|
||||
|
||||
return (
|
||||
<InputPopover
|
||||
placement="left"
|
||||
trigger="click"
|
||||
header={
|
||||
<>
|
||||
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
|
||||
<header>
|
||||
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
||||
</>
|
||||
}
|
||||
content={
|
||||
</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>
|
||||
);
|
||||
}
|
||||
onOk={this.save}
|
||||
onCancel={this.hide}
|
||||
okButtonProps={{ disabled: !!inputError }}
|
||||
|
||||
render() {
|
||||
const { visible, mapping } = this.state;
|
||||
return (
|
||||
<Popover
|
||||
placement="left"
|
||||
trigger="click"
|
||||
content={this.renderContent()}
|
||||
visible={visible}
|
||||
onVisibleChange={this.onVisibleChange}>
|
||||
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
|
||||
<EditOutlinedIcon />
|
||||
</Button>
|
||||
</InputPopover>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~antd/lib/modal/style/index"; // for ant @vars
|
||||
@import (reference, less) "~@/assets/less/ant"; // for ant @vars
|
||||
|
||||
.parameters-mapping-list {
|
||||
.keyword {
|
||||
@@ -22,6 +22,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-mapping-editor {
|
||||
width: 390px;
|
||||
|
||||
.radio {
|
||||
display: block;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 0 16px 10px;
|
||||
margin: 0 -16px 20px;
|
||||
border-bottom: @border-width-base @border-style-base @border-color-split;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 500;
|
||||
color: @heading-color;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: @border-width-base @border-style-base @border-color-split;
|
||||
padding: 10px 16px 0;
|
||||
margin: 0 -16px;
|
||||
text-align: right;
|
||||
|
||||
button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-mapping-title {
|
||||
.text {
|
||||
margin-right: 3px;
|
||||
|
||||
@@ -101,7 +101,6 @@ class ParameterValueInput extends React.Component {
|
||||
<SelectWithVirtualScroll
|
||||
className={this.props.className}
|
||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||
optionFilterProp="children"
|
||||
value={normalize(value)}
|
||||
onChange={this.onSelect}
|
||||
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
|
||||
@@ -120,7 +119,6 @@ class ParameterValueInput extends React.Component {
|
||||
<QueryBasedParameterInput
|
||||
className={this.props.className}
|
||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||
optionFilterProp="children"
|
||||
parameter={parameter}
|
||||
value={value}
|
||||
queryId={queryId}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~antd/lib/input-number/style/index"; // for ant @vars
|
||||
@import (reference, less) "~@/assets/less/ant"; // for ant @vars
|
||||
|
||||
@input-dirty: #fffce1;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
.@{ant-prefix}-input-number,
|
||||
.@{ant-prefix}-select-selector,
|
||||
.@{ant-prefix}-picker {
|
||||
background-color: @input-dirty !important;
|
||||
background-color: @input-dirty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Parameter, createParameter } from "@/services/parameters";
|
||||
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
||||
import { toHuman } from "@/lib/utils";
|
||||
|
||||
import "./Parameters.less";
|
||||
|
||||
@@ -22,19 +23,23 @@ export default class Parameters extends React.Component {
|
||||
static propTypes = {
|
||||
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
|
||||
editable: PropTypes.bool,
|
||||
sortable: PropTypes.bool,
|
||||
disableUrlUpdate: PropTypes.bool,
|
||||
onValuesChange: PropTypes.func,
|
||||
onPendingValuesChange: PropTypes.func,
|
||||
onParametersEdit: PropTypes.func,
|
||||
appendSortableToParent: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
parameters: [],
|
||||
editable: false,
|
||||
sortable: false,
|
||||
disableUrlUpdate: false,
|
||||
onValuesChange: () => {},
|
||||
onPendingValuesChange: () => {},
|
||||
onParametersEdit: () => {},
|
||||
appendSortableToParent: true,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
@@ -84,7 +89,7 @@ export default class Parameters extends React.Component {
|
||||
if (oldIndex !== newIndex) {
|
||||
this.setState(({ parameters }) => {
|
||||
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
|
||||
onParametersEdit();
|
||||
onParametersEdit(parameters);
|
||||
return { parameters };
|
||||
});
|
||||
}
|
||||
@@ -109,7 +114,7 @@ export default class Parameters extends React.Component {
|
||||
this.setState(({ parameters }) => {
|
||||
const updatedParameter = extend(parameter, updated);
|
||||
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
|
||||
onParametersEdit();
|
||||
onParametersEdit(parameters);
|
||||
return { parameters };
|
||||
});
|
||||
});
|
||||
@@ -120,7 +125,7 @@ export default class Parameters extends React.Component {
|
||||
return (
|
||||
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
||||
<div className="parameter-heading">
|
||||
<label>{param.getTitle()}</label>
|
||||
<label>{param.title || toHuman(param.name)}</label>
|
||||
{editable && (
|
||||
<button
|
||||
className="btn btn-default btn-xs m-l-5"
|
||||
@@ -145,15 +150,17 @@ export default class Parameters extends React.Component {
|
||||
|
||||
render() {
|
||||
const { parameters } = this.state;
|
||||
const { editable } = this.props;
|
||||
const { sortable, appendSortableToParent } = this.props;
|
||||
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
|
||||
|
||||
return (
|
||||
<SortableContainer
|
||||
disabled={!editable}
|
||||
disabled={!sortable}
|
||||
axis="xy"
|
||||
useDragHandle
|
||||
lockToContainerEdges
|
||||
helperClass="parameter-dragged"
|
||||
helperContainer={containerEl => (appendSortableToParent ? containerEl : document.body)}
|
||||
updateBeforeSortStart={this.onBeforeSortStart}
|
||||
onSortEnd={this.moveParameter}
|
||||
containerProps={{
|
||||
@@ -162,8 +169,11 @@ export default class Parameters extends React.Component {
|
||||
}}>
|
||||
{parameters.map((param, index) => (
|
||||
<SortableElement key={param.name} index={index}>
|
||||
<div className="parameter-block" data-editable={editable || null}>
|
||||
{editable && <DragHandle data-test={`DragHandle-${param.name}`} />}
|
||||
<div
|
||||
className="parameter-block"
|
||||
data-editable={sortable || null}
|
||||
data-test={`ParameterBlock-${param.name}`}>
|
||||
{sortable && <DragHandle data-test={`DragHandle-${param.name}`} />}
|
||||
{this.renderParameter(param, index)}
|
||||
</div>
|
||||
</SortableElement>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "../assets/less/ant";
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
.parameter-block {
|
||||
display: inline-block;
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
&.parameter-dragged {
|
||||
z-index: 2;
|
||||
margin: 4px 0 0 4px;
|
||||
padding: 3px 6px 6px;
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import { find, isArray, get, first, map, intersection, isEqual, isEmpty, trim, debounce, isNil } from "lodash";
|
||||
import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
||||
|
||||
const SEARCH_DEBOUNCE_TIME = 300;
|
||||
|
||||
function filterValuesThatAreNotInOptions(value, options) {
|
||||
if (isArray(value)) {
|
||||
const optionValues = map(options, option => option.value);
|
||||
return intersection(value, optionValues);
|
||||
}
|
||||
const found = find(options, option => option.value === value) !== undefined;
|
||||
return found ? value : get(first(options), "value");
|
||||
}
|
||||
|
||||
export default class QueryBasedParameterInput extends React.Component {
|
||||
static propTypes = {
|
||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
@@ -39,7 +28,6 @@ export default class QueryBasedParameterInput extends React.Component {
|
||||
options: [],
|
||||
value: null,
|
||||
loading: false,
|
||||
currentSearchTerm: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,10 +36,9 @@ export default class QueryBasedParameterInput extends React.Component {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.queryId !== prevProps.queryId || this.props.parameter !== prevProps.parameter) {
|
||||
if (this.props.queryId !== prevProps.queryId) {
|
||||
this._loadOptions(this.props.queryId);
|
||||
}
|
||||
|
||||
if (this.props.value !== prevProps.value) {
|
||||
this.setValue(this.props.value);
|
||||
}
|
||||
@@ -59,26 +46,26 @@ export default class QueryBasedParameterInput extends React.Component {
|
||||
|
||||
setValue(value) {
|
||||
const { options } = this.state;
|
||||
const { mode, parameter } = this.props;
|
||||
|
||||
if (mode === "multiple") {
|
||||
if (isNil(value)) {
|
||||
value = [];
|
||||
}
|
||||
|
||||
if (this.props.mode === "multiple") {
|
||||
value = isArray(value) ? value : [value];
|
||||
const optionValues = map(options, option => option.value);
|
||||
const validValues = intersection(value, optionValues);
|
||||
this.setState({ value: validValues });
|
||||
return validValues;
|
||||
}
|
||||
|
||||
// parameters with search don't have options available, so we trust what we get
|
||||
if (!parameter.searchFunction) {
|
||||
value = filterValuesThatAreNotInOptions(value, options);
|
||||
}
|
||||
|
||||
const found = find(options, option => option.value === this.props.value) !== undefined;
|
||||
value = found ? value : get(first(options), "value");
|
||||
this.setState({ value });
|
||||
return value;
|
||||
}
|
||||
|
||||
updateOptions(options) {
|
||||
async _loadOptions(queryId) {
|
||||
if (queryId && queryId !== this.state.queryId) {
|
||||
this.setState({ loading: true });
|
||||
const options = await this.props.parameter.loadDropdownValues();
|
||||
|
||||
// stale queryId check
|
||||
if (this.props.queryId === queryId) {
|
||||
this.setState({ options, loading: false }, () => {
|
||||
const updatedValue = this.setValue(this.props.value);
|
||||
if (!isEqual(updatedValue, this.props.value)) {
|
||||
@@ -86,58 +73,26 @@ export default class QueryBasedParameterInput extends React.Component {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _loadOptions(queryId) {
|
||||
if (queryId && queryId !== this.state.queryId) {
|
||||
this.setState({ loading: true });
|
||||
const options = await this.props.parameter.loadDropdownValues(this.state.currentSearchTerm);
|
||||
|
||||
// stale queryId check
|
||||
if (this.props.queryId === queryId) {
|
||||
this.updateOptions(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchFunction = debounce(searchTerm => {
|
||||
const { parameter } = this.props;
|
||||
if (parameter.searchFunction && trim(searchTerm)) {
|
||||
this.setState({ loading: true, currentSearchTerm: searchTerm });
|
||||
parameter.searchFunction(searchTerm).then(options => {
|
||||
if (this.state.currentSearchTerm === searchTerm) {
|
||||
this.updateOptions(options);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, SEARCH_DEBOUNCE_TIME);
|
||||
|
||||
render() {
|
||||
const { parameter, className, mode, onSelect, queryId, value, ...otherProps } = this.props;
|
||||
const { className, mode, onSelect, queryId, value, ...otherProps } = this.props;
|
||||
const { loading, options } = this.state;
|
||||
const selectProps = { ...otherProps };
|
||||
|
||||
if (parameter.searchColumn) {
|
||||
selectProps.filterOption = false;
|
||||
selectProps.onSearch = this.searchFunction;
|
||||
selectProps.onChange = value => onSelect(parameter.normalizeValue(value));
|
||||
selectProps.notFoundContent = null;
|
||||
selectProps.labelInValue = true;
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
<SelectWithVirtualScroll
|
||||
className={className}
|
||||
disabled={!parameter.searchFunction && loading}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
mode={mode}
|
||||
value={this.state.value || undefined}
|
||||
value={this.state.value}
|
||||
onChange={onSelect}
|
||||
options={options}
|
||||
optionFilterProp="children"
|
||||
options={map(options, ({ value, name }) => ({ label: String(name), value }))}
|
||||
showSearch
|
||||
showArrow
|
||||
notFoundContent={isEmpty(options) ? "No options available" : null}
|
||||
{...selectProps}
|
||||
{...otherProps}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ interface VirtualScrollLabeledValue extends LabeledValue {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface VirtualScrollSelectProps extends SelectProps<string> {
|
||||
interface VirtualScrollSelectProps extends Omit<SelectProps<string>, "optionFilterProp" | "children"> {
|
||||
options: Array<VirtualScrollLabeledValue>;
|
||||
}
|
||||
function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps): JSX.Element {
|
||||
@@ -32,7 +32,14 @@ function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps
|
||||
return false;
|
||||
}, [options]);
|
||||
|
||||
return <AntdSelect<string> dropdownMatchSelectWidth={dropdownMatchSelectWidth} options={options} {...props} />;
|
||||
return (
|
||||
<AntdSelect<string>
|
||||
dropdownMatchSelectWidth={dropdownMatchSelectWidth}
|
||||
options={options}
|
||||
optionFilterProp="label" // as this component expects "options" prop
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectWithVirtualScroll;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~@/assets/less/ant";
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
.tags-list {
|
||||
.tags-list-title {
|
||||
|
||||
@@ -35,11 +35,11 @@ CounterCard.defaultProps = {
|
||||
|
||||
const queryJobsColumns = [
|
||||
{ title: "Queue", dataIndex: "origin" },
|
||||
{ title: "Query ID", dataIndex: "meta.query_id" },
|
||||
{ title: "Org ID", dataIndex: "meta.org_id" },
|
||||
{ title: "Data Source ID", dataIndex: "meta.data_source_id" },
|
||||
{ title: "User ID", dataIndex: "meta.user_id" },
|
||||
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: "meta.scheduled" }),
|
||||
{ title: "Query ID", dataIndex: ["meta", "query_id"] },
|
||||
{ title: "Org ID", dataIndex: ["meta", "org_id"] },
|
||||
{ title: "Data Source ID", dataIndex: ["meta", "data_source_id"] },
|
||||
{ title: "User ID", dataIndex: ["meta", "user_id"] },
|
||||
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: ["meta", "scheduled"] }),
|
||||
Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }),
|
||||
Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }),
|
||||
];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
@import '../../assets/less/inc/variables';
|
||||
@import (reference, less) "~@/assets/less/inc/variables";
|
||||
|
||||
.visual-card-list {
|
||||
width: 100%;
|
||||
@@ -7,7 +6,7 @@
|
||||
}
|
||||
|
||||
.visual-card {
|
||||
background: #FFFFFF;
|
||||
background: #ffffff;
|
||||
border: 1px solid fade(@redash-gray, 15%);
|
||||
border-radius: 3px;
|
||||
margin: 5px;
|
||||
|
||||
@@ -41,6 +41,7 @@ const DashboardWidget = React.memo(
|
||||
onRefreshWidget,
|
||||
onRemoveWidget,
|
||||
onParameterMappingsChange,
|
||||
isEditing,
|
||||
canEdit,
|
||||
isPublic,
|
||||
isLoading,
|
||||
@@ -57,6 +58,7 @@ const DashboardWidget = React.memo(
|
||||
widget={widget}
|
||||
dashboard={dashboard}
|
||||
filters={filters}
|
||||
isEditing={isEditing}
|
||||
canEdit={canEdit}
|
||||
isPublic={isPublic}
|
||||
isLoading={isLoading}
|
||||
@@ -77,7 +79,8 @@ const DashboardWidget = React.memo(
|
||||
prevProps.canEdit === nextProps.canEdit &&
|
||||
prevProps.isPublic === nextProps.isPublic &&
|
||||
prevProps.isLoading === nextProps.isLoading &&
|
||||
prevProps.filters === nextProps.filters
|
||||
prevProps.filters === nextProps.filters &&
|
||||
prevProps.isEditing === nextProps.isEditing
|
||||
);
|
||||
|
||||
class DashboardGrid extends React.Component {
|
||||
@@ -223,7 +226,6 @@ class DashboardGrid extends React.Component {
|
||||
});
|
||||
|
||||
render() {
|
||||
const className = cx("dashboard-wrapper", this.props.isEditing ? "editing-mode" : "preview-mode");
|
||||
const {
|
||||
onLoadWidget,
|
||||
onRefreshWidget,
|
||||
@@ -232,19 +234,21 @@ class DashboardGrid extends React.Component {
|
||||
filters,
|
||||
dashboard,
|
||||
isPublic,
|
||||
isEditing,
|
||||
widgets,
|
||||
} = this.props;
|
||||
const className = cx("dashboard-wrapper", isEditing ? "editing-mode" : "preview-mode");
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveGridLayout
|
||||
draggableCancel="input"
|
||||
draggableCancel="input,.sortable-container"
|
||||
className={cx("layout", { "disable-animations": this.state.disableAnimations })}
|
||||
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
|
||||
rowHeight={cfg.rowHeight - cfg.margins}
|
||||
margin={[cfg.margins, cfg.margins]}
|
||||
isDraggable={this.props.isEditing}
|
||||
isResizable={this.props.isEditing}
|
||||
isDraggable={isEditing}
|
||||
isResizable={isEditing}
|
||||
onResizeStart={this.autoHeightCtrl.stop}
|
||||
onResizeStop={this.onWidgetResize}
|
||||
layouts={this.state.layouts}
|
||||
@@ -266,6 +270,7 @@ class DashboardGrid extends React.Component {
|
||||
filters={filters}
|
||||
isPublic={isPublic}
|
||||
isLoading={widget.loading}
|
||||
isEditing={isEditing}
|
||||
canEdit={dashboard.canEdit()}
|
||||
onLoadWidget={onLoadWidget}
|
||||
onRefreshWidget={onRefreshWidget}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { compact, isEmpty, invoke } from "lodash";
|
||||
import { compact, isEmpty, invoke, map } from "lodash";
|
||||
import { markdown } from "markdown";
|
||||
import cx from "classnames";
|
||||
import Menu from "antd/lib/menu";
|
||||
@@ -84,7 +84,14 @@ function RefreshIndicator({ refreshStartedAt }) {
|
||||
RefreshIndicator.propTypes = { refreshStartedAt: Moment };
|
||||
RefreshIndicator.defaultProps = { refreshStartedAt: null };
|
||||
|
||||
function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, onParametersUpdate }) {
|
||||
function VisualizationWidgetHeader({
|
||||
widget,
|
||||
refreshStartedAt,
|
||||
parameters,
|
||||
isEditing,
|
||||
onParametersUpdate,
|
||||
onParametersEdit,
|
||||
}) {
|
||||
const canViewQuery = currentUser.hasPermission("view_query");
|
||||
|
||||
return (
|
||||
@@ -104,7 +111,13 @@ function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, onPar
|
||||
</div>
|
||||
{!isEmpty(parameters) && (
|
||||
<div className="m-b-10">
|
||||
<Parameters parameters={parameters} onValuesChange={onParametersUpdate} />
|
||||
<Parameters
|
||||
parameters={parameters}
|
||||
sortable={isEditing}
|
||||
appendSortableToParent={false}
|
||||
onValuesChange={onParametersUpdate}
|
||||
onParametersEdit={onParametersEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -115,12 +128,16 @@ VisualizationWidgetHeader.propTypes = {
|
||||
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
refreshStartedAt: Moment,
|
||||
parameters: PropTypes.arrayOf(PropTypes.object),
|
||||
isEditing: PropTypes.bool,
|
||||
onParametersUpdate: PropTypes.func,
|
||||
onParametersEdit: PropTypes.func,
|
||||
};
|
||||
|
||||
VisualizationWidgetHeader.defaultProps = {
|
||||
refreshStartedAt: null,
|
||||
onParametersUpdate: () => {},
|
||||
onParametersEdit: () => {},
|
||||
isEditing: false,
|
||||
parameters: [],
|
||||
};
|
||||
|
||||
@@ -190,6 +207,7 @@ class VisualizationWidget extends React.Component {
|
||||
isPublic: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
canEdit: PropTypes.bool,
|
||||
isEditing: PropTypes.bool,
|
||||
onLoad: PropTypes.func,
|
||||
onRefresh: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
@@ -201,6 +219,7 @@ class VisualizationWidget extends React.Component {
|
||||
isPublic: false,
|
||||
isLoading: false,
|
||||
canEdit: false,
|
||||
isEditing: false,
|
||||
onLoad: () => {},
|
||||
onRefresh: () => {},
|
||||
onDelete: () => {},
|
||||
@@ -284,10 +303,15 @@ class VisualizationWidget extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { widget, isLoading, isPublic, canEdit, onRefresh } = this.props;
|
||||
const { widget, isLoading, isPublic, canEdit, isEditing, onRefresh } = this.props;
|
||||
const { localParameters } = this.state;
|
||||
const widgetQueryResult = widget.getQueryResult();
|
||||
const isRefreshing = isLoading && !!(widgetQueryResult && widgetQueryResult.getStatus());
|
||||
const onParametersEdit = parameters => {
|
||||
const paramOrder = map(parameters, "name");
|
||||
widget.options.paramOrder = paramOrder;
|
||||
widget.save("options", { paramOrder });
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget
|
||||
@@ -303,7 +327,9 @@ class VisualizationWidget extends React.Component {
|
||||
widget={widget}
|
||||
refreshStartedAt={isRefreshing ? widget.refreshStartedAt : null}
|
||||
parameters={localParameters}
|
||||
isEditing={isEditing}
|
||||
onParametersUpdate={onRefresh}
|
||||
onParametersEdit={onParametersEdit}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "../../../assets/less/inc/variables";
|
||||
@import (reference, less) "~@/assets/less/inc/variables";
|
||||
|
||||
.tile .t-header .th-title a.query-link {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~@/assets/less/ant";
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
@btn-extra-options-bg: fade(@redash-gray, 10%);
|
||||
@btn-extra-options-border: fade(@redash-gray, 15%);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "../../assets/less/inc/variables";
|
||||
@import (reference, less) "~@/assets/less/inc/variables";
|
||||
|
||||
.date-range-parameter,
|
||||
.date-parameter {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Empty states
|
||||
.empty-state {
|
||||
width: 100%;
|
||||
margin: 0px auto 10px;
|
||||
margin: 0 auto 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
|
||||
.empty-state__steps {
|
||||
padding-left: 0px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.empty-state__summary {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '~antd/lib/button/style/index';
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
.embed-query-dialog {
|
||||
label {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import (reference, less) '~@/assets/less/main.less';
|
||||
@import (reference, less) "~@/assets/less/main.less";
|
||||
|
||||
.ant-list {
|
||||
&.add-to-dashboard-dialog-search-results {
|
||||
@@ -13,7 +13,8 @@
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover, &:active {
|
||||
&:hover,
|
||||
&:active {
|
||||
@table-row-hover-bg: fade(@redash-gray, 5%);
|
||||
background-color: @table-row-hover-bg;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~@/assets/less/ant";
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
.databricks-schema-browser {
|
||||
.schema-control {
|
||||
|
||||
@@ -9,6 +9,7 @@ function getQueryResultData(queryResult, queryResultStatus = null) {
|
||||
filters: invoke(queryResult, "getFilters") || [],
|
||||
updatedAt: invoke(queryResult, "getUpdatedAt") || null,
|
||||
retrievedAt: get(queryResult, "query_result.retrieved_at", null),
|
||||
truncated: invoke(queryResult, "getTruncated") || null,
|
||||
log: invoke(queryResult, "getLog") || [],
|
||||
error: invoke(queryResult, "getError") || null,
|
||||
runtime: invoke(queryResult, "getRuntime") || null,
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) {
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<a onClick={confirmDelete}>Delete Alert</a>
|
||||
<a onClick={confirmDelete}>Delete</a>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
}>
|
||||
|
||||
@@ -30,6 +30,13 @@ const sidebarMenu = [
|
||||
key: "all",
|
||||
href: "dashboards",
|
||||
title: "All Dashboards",
|
||||
icon: () => <Sidebar.MenuIcon icon="zmdi zmdi-view-quilt" />,
|
||||
},
|
||||
{
|
||||
key: "my",
|
||||
href: "dashboards/my",
|
||||
title: "My Dashboards",
|
||||
icon: () => <Sidebar.ProfileImage user={currentUser} />,
|
||||
},
|
||||
{
|
||||
key: "favorites",
|
||||
@@ -157,6 +164,7 @@ const DashboardListPage = itemsList(
|
||||
getResource({ params: { currentPage } }) {
|
||||
return {
|
||||
all: Dashboard.query.bind(Dashboard),
|
||||
my: Dashboard.myDashboards.bind(Dashboard),
|
||||
favorites: Dashboard.favorites.bind(Dashboard),
|
||||
}[currentPage];
|
||||
},
|
||||
@@ -183,3 +191,11 @@ routes.register(
|
||||
render: pageProps => <DashboardListPage {...pageProps} currentPage="favorites" />,
|
||||
})
|
||||
);
|
||||
routes.register(
|
||||
"Dashboards.My",
|
||||
routeWithUserSession({
|
||||
path: "/dashboards/my",
|
||||
title: "My Dashboards",
|
||||
render: pageProps => <DashboardListPage {...pageProps} currentPage="my" />,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEmpty } from "lodash";
|
||||
import { isEmpty, map } from "lodash";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
@@ -24,8 +24,8 @@ import DashboardHeader from "./components/DashboardHeader";
|
||||
|
||||
import "./DashboardPage.less";
|
||||
|
||||
function DashboardSettings({ dashboardOptions }) {
|
||||
const { dashboard, updateDashboard } = dashboardOptions;
|
||||
function DashboardSettings({ dashboardConfiguration }) {
|
||||
const { dashboard, updateDashboard } = dashboardConfiguration;
|
||||
return (
|
||||
<div className="m-b-10 p-15 bg-white tiled">
|
||||
<Checkbox
|
||||
@@ -39,11 +39,11 @@ function DashboardSettings({ dashboardOptions }) {
|
||||
}
|
||||
|
||||
DashboardSettings.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
function AddWidgetContainer({ dashboardOptions, className, ...props }) {
|
||||
const { showAddTextboxDialog, showAddWidgetDialog } = dashboardOptions;
|
||||
function AddWidgetContainer({ dashboardConfiguration, className, ...props }) {
|
||||
const { showAddTextboxDialog, showAddWidgetDialog } = dashboardConfiguration;
|
||||
return (
|
||||
<div className={cx("add-widget-container", className)} {...props}>
|
||||
<h2>
|
||||
@@ -66,12 +66,12 @@ function AddWidgetContainer({ dashboardOptions, className, ...props }) {
|
||||
}
|
||||
|
||||
AddWidgetContainer.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
function DashboardComponent(props) {
|
||||
const dashboardOptions = useDashboard(props.dashboard);
|
||||
const dashboardConfiguration = useDashboard(props.dashboard);
|
||||
const {
|
||||
dashboard,
|
||||
filters,
|
||||
@@ -81,14 +81,19 @@ function DashboardComponent(props) {
|
||||
removeWidget,
|
||||
saveDashboardLayout,
|
||||
globalParameters,
|
||||
updateDashboard,
|
||||
refreshDashboard,
|
||||
refreshWidget,
|
||||
editingLayout,
|
||||
setGridDisabled,
|
||||
} = dashboardOptions;
|
||||
} = dashboardConfiguration;
|
||||
|
||||
const [pageContainer, setPageContainer] = useState(null);
|
||||
const [bottomPanelStyles, setBottomPanelStyles] = useState({});
|
||||
const onParametersEdit = parameters => {
|
||||
const paramOrder = map(parameters, "name");
|
||||
updateDashboard({ options: { globalParamOrder: paramOrder } });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (pageContainer) {
|
||||
@@ -114,14 +119,23 @@ function DashboardComponent(props) {
|
||||
return (
|
||||
<div className="container" ref={setPageContainer} data-test={`DashboardId${dashboard.id}Container`}>
|
||||
<DashboardHeader
|
||||
dashboardOptions={dashboardOptions}
|
||||
dashboardConfiguration={dashboardConfiguration}
|
||||
headerExtra={
|
||||
<DynamicComponent name="Dashboard.HeaderExtra" dashboard={dashboard} dashboardOptions={dashboardOptions} />
|
||||
<DynamicComponent
|
||||
name="Dashboard.HeaderExtra"
|
||||
dashboard={dashboard}
|
||||
dashboardConfiguration={dashboardConfiguration}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!isEmpty(globalParameters) && (
|
||||
<div className="dashboard-parameters m-b-10 p-15 bg-white tiled" data-test="DashboardParameters">
|
||||
<Parameters parameters={globalParameters} onValuesChange={refreshDashboard} />
|
||||
<Parameters
|
||||
parameters={globalParameters}
|
||||
onValuesChange={refreshDashboard}
|
||||
sortable={editingLayout}
|
||||
onParametersEdit={onParametersEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(filters) && (
|
||||
@@ -129,7 +143,7 @@ function DashboardComponent(props) {
|
||||
<Filters filters={filters} onChange={setFilters} />
|
||||
</div>
|
||||
)}
|
||||
{editingLayout && <DashboardSettings dashboardOptions={dashboardOptions} />}
|
||||
{editingLayout && <DashboardSettings dashboardConfiguration={dashboardConfiguration} />}
|
||||
<div id="dashboard-container">
|
||||
<DashboardGrid
|
||||
dashboard={dashboard}
|
||||
@@ -144,7 +158,9 @@ function DashboardComponent(props) {
|
||||
onParameterMappingsChange={loadDashboard}
|
||||
/>
|
||||
</div>
|
||||
{editingLayout && <AddWidgetContainer dashboardOptions={dashboardOptions} style={bottomPanelStyles} />}
|
||||
{editingLayout && (
|
||||
<AddWidgetContainer dashboardConfiguration={dashboardConfiguration} style={bottomPanelStyles} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~@/assets/less/inc/variables";
|
||||
@import (reference, less) "~@/assets/less/inc/variables";
|
||||
|
||||
/****
|
||||
grid bg - based on 6 cols, 35px rows and 15px spacing
|
||||
|
||||
@@ -27,8 +27,8 @@ function buttonType(value) {
|
||||
return value ? "primary" : "default";
|
||||
}
|
||||
|
||||
function DashboardPageTitle({ dashboardOptions }) {
|
||||
const { dashboard, canEditDashboard, updateDashboard, editingLayout } = dashboardOptions;
|
||||
function DashboardPageTitle({ dashboardConfiguration }) {
|
||||
const { dashboard, canEditDashboard, updateDashboard, editingLayout } = dashboardConfiguration;
|
||||
return (
|
||||
<div className="title-with-tags">
|
||||
<div className="page-title">
|
||||
@@ -58,11 +58,11 @@ function DashboardPageTitle({ dashboardOptions }) {
|
||||
}
|
||||
|
||||
DashboardPageTitle.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
function RefreshButton({ dashboardOptions }) {
|
||||
const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardOptions;
|
||||
function RefreshButton({ dashboardConfiguration }) {
|
||||
const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardConfiguration;
|
||||
const allowedIntervals = policy.getDashboardRefreshIntervals();
|
||||
const refreshRateOptions = clientConfig.dashboardRefreshIntervals;
|
||||
const onRefreshRateSelected = ({ key }) => {
|
||||
@@ -105,10 +105,10 @@ function RefreshButton({ dashboardOptions }) {
|
||||
}
|
||||
|
||||
RefreshButton.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
function DashboardMoreOptionsButton({ dashboardOptions }) {
|
||||
function DashboardMoreOptionsButton({ dashboardConfiguration }) {
|
||||
const {
|
||||
dashboard,
|
||||
setEditingLayout,
|
||||
@@ -117,7 +117,7 @@ function DashboardMoreOptionsButton({ dashboardOptions }) {
|
||||
managePermissions,
|
||||
gridDisabled,
|
||||
isDashboardOwnerOrAdmin,
|
||||
} = dashboardOptions;
|
||||
} = dashboardConfiguration;
|
||||
|
||||
const archive = () => {
|
||||
Modal.confirm({
|
||||
@@ -163,10 +163,10 @@ function DashboardMoreOptionsButton({ dashboardOptions }) {
|
||||
}
|
||||
|
||||
DashboardMoreOptionsButton.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
function DashboardControl({ dashboardOptions, headerExtra }) {
|
||||
function DashboardControl({ dashboardConfiguration, headerExtra }) {
|
||||
const {
|
||||
dashboard,
|
||||
togglePublished,
|
||||
@@ -174,7 +174,7 @@ function DashboardControl({ dashboardOptions, headerExtra }) {
|
||||
fullscreen,
|
||||
toggleFullscreen,
|
||||
showShareDashboardDialog,
|
||||
} = dashboardOptions;
|
||||
} = dashboardConfiguration;
|
||||
const showPublishButton = dashboard.is_draft;
|
||||
const showRefreshButton = true;
|
||||
const showFullscreenButton = !dashboard.is_draft;
|
||||
@@ -190,7 +190,7 @@ function DashboardControl({ dashboardOptions, headerExtra }) {
|
||||
<span className="fa fa-paper-plane m-r-5" /> Publish
|
||||
</Button>
|
||||
)}
|
||||
{showRefreshButton && <RefreshButton dashboardOptions={dashboardOptions} />}
|
||||
{showRefreshButton && <RefreshButton dashboardConfiguration={dashboardConfiguration} />}
|
||||
{showFullscreenButton && (
|
||||
<Tooltip className="hidden-xs" title="Enable/Disable Fullscreen display">
|
||||
<Button type={buttonType(fullscreen)} className="icon-button m-l-5" onClick={toggleFullscreen}>
|
||||
@@ -210,7 +210,7 @@ function DashboardControl({ dashboardOptions, headerExtra }) {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showMoreOptionsButton && <DashboardMoreOptionsButton dashboardOptions={dashboardOptions} />}
|
||||
{showMoreOptionsButton && <DashboardMoreOptionsButton dashboardConfiguration={dashboardConfiguration} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -218,12 +218,17 @@ function DashboardControl({ dashboardOptions, headerExtra }) {
|
||||
}
|
||||
|
||||
DashboardControl.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
headerExtra: PropTypes.node,
|
||||
};
|
||||
|
||||
function DashboardEditControl({ dashboardOptions, headerExtra }) {
|
||||
const { setEditingLayout, doneBtnClickedWhileSaving, dashboardStatus, retrySaveDashboardLayout } = dashboardOptions;
|
||||
function DashboardEditControl({ dashboardConfiguration, headerExtra }) {
|
||||
const {
|
||||
setEditingLayout,
|
||||
doneBtnClickedWhileSaving,
|
||||
dashboardStatus,
|
||||
retrySaveDashboardLayout,
|
||||
} = dashboardConfiguration;
|
||||
let status;
|
||||
if (dashboardStatus === DashboardStatusEnum.SAVED) {
|
||||
status = <span className="save-status">Saved</span>;
|
||||
@@ -258,23 +263,23 @@ function DashboardEditControl({ dashboardOptions, headerExtra }) {
|
||||
}
|
||||
|
||||
DashboardEditControl.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
headerExtra: PropTypes.node,
|
||||
};
|
||||
|
||||
export default function DashboardHeader({ dashboardOptions, headerExtra }) {
|
||||
const { editingLayout } = dashboardOptions;
|
||||
export default function DashboardHeader({ dashboardConfiguration, headerExtra }) {
|
||||
const { editingLayout } = dashboardConfiguration;
|
||||
const DashboardControlComponent = editingLayout ? DashboardEditControl : DashboardControl;
|
||||
|
||||
return (
|
||||
<div className="dashboard-header">
|
||||
<DashboardPageTitle dashboardOptions={dashboardOptions} />
|
||||
<DashboardControlComponent dashboardOptions={dashboardOptions} headerExtra={headerExtra} />
|
||||
<DashboardPageTitle dashboardConfiguration={dashboardConfiguration} />
|
||||
<DashboardControlComponent dashboardConfiguration={dashboardConfiguration} headerExtra={headerExtra} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DashboardHeader.propTypes = {
|
||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
headerExtra: PropTypes.node,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~@/components/ApplicationArea/ApplicationLayout/index.less";
|
||||
@import (reference, less) "~@/components/ApplicationArea/ApplicationLayout/index.less";
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
|
||||
@@ -4,6 +4,10 @@ import BigMessage from "@/components/BigMessage";
|
||||
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
|
||||
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import Link from "@/components/Link";
|
||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||
import { currentUser } from "@/services/auth";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
|
||||
export interface DashboardListEmptyStateProps {
|
||||
page: string;
|
||||
@@ -21,6 +25,20 @@ export default function DashboardListEmptyState({ page, searchTerm, selectedTags
|
||||
switch (page) {
|
||||
case "favorites":
|
||||
return <BigMessage message="Mark dashboards as Favorite to list them here." icon="fa-star" />;
|
||||
case "my":
|
||||
const my_msg = currentUser.hasPermission("create_dashboard") ? (
|
||||
<span>
|
||||
<Link.Button type="primary" size="small" onClick={() => CreateDashboardDialog.showModal()}>
|
||||
Create your first dashboard!
|
||||
</Link.Button>{" "}
|
||||
<HelpTrigger className="f-14" type="DASHBOARDS" showTooltip={false}>
|
||||
Need help?
|
||||
</HelpTrigger>
|
||||
</span>
|
||||
) : (
|
||||
<span>Sorry, we couldn't find anything.</span>
|
||||
);
|
||||
return <BigMessage icon="fa-search">{my_msg}</BigMessage>;
|
||||
default:
|
||||
return (
|
||||
<DynamicComponent name="DashboardList.EmptyState">
|
||||
|
||||
@@ -33,19 +33,19 @@ const sidebarMenu = [
|
||||
key: "all",
|
||||
href: "queries",
|
||||
title: "All Queries",
|
||||
},
|
||||
{
|
||||
key: "favorites",
|
||||
href: "queries/favorites",
|
||||
title: "Favorites",
|
||||
icon: () => <Sidebar.MenuIcon icon="fa fa-star" />,
|
||||
icon: () => <Sidebar.MenuIcon icon="fa fa-code" />,
|
||||
},
|
||||
{
|
||||
key: "my",
|
||||
href: "queries/my",
|
||||
title: "My Queries",
|
||||
icon: () => <Sidebar.ProfileImage user={currentUser} />,
|
||||
isAvailable: () => currentUser.hasPermission("create_query"),
|
||||
},
|
||||
{
|
||||
key: "favorites",
|
||||
href: "queries/favorites",
|
||||
title: "Favorites",
|
||||
icon: () => <Sidebar.MenuIcon icon="fa fa-star" />,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
|
||||
@@ -5,6 +5,8 @@ import BigMessage from "@/components/BigMessage";
|
||||
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
|
||||
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import { currentUser } from "@/services/auth";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
|
||||
export default function QueriesListEmptyState({ page, searchTerm, selectedTags }) {
|
||||
if (searchTerm !== "") {
|
||||
@@ -19,15 +21,19 @@ export default function QueriesListEmptyState({ page, searchTerm, selectedTags }
|
||||
case "archive":
|
||||
return <BigMessage message="Archived queries will be listed here." icon="fa-archive" />;
|
||||
case "my":
|
||||
return (
|
||||
<div className="tiled bg-white p-15">
|
||||
const my_msg = currentUser.hasPermission("create_query") ? (
|
||||
<span>
|
||||
<Link.Button href="queries/new" type="primary" size="small">
|
||||
Create your first query
|
||||
Create your first query!
|
||||
</Link.Button>{" "}
|
||||
to populate My Queries list. Need help? Check out our{" "}
|
||||
<Link href="https://redash.io/help/user-guide/querying/writing-queries">query writing documentation</Link>.
|
||||
</div>
|
||||
<HelpTrigger className="f-13" type="QUERIES" showTooltip={false}>
|
||||
Need help?
|
||||
</HelpTrigger>
|
||||
</span>
|
||||
) : (
|
||||
<span>Sorry, we couldn't find anything.</span>
|
||||
);
|
||||
return <BigMessage icon="fa-search">{my_msg}</BigMessage>;
|
||||
default:
|
||||
return (
|
||||
<DynamicComponent name="QueriesList.EmptyState">
|
||||
|
||||
@@ -336,6 +336,7 @@ function QuerySource(props) {
|
||||
<div className="query-parameters-wrapper">
|
||||
<Parameters
|
||||
editable={queryFlags.canEdit}
|
||||
sortable={queryFlags.canEdit}
|
||||
disableUrlUpdate={queryFlags.isNew}
|
||||
parameters={parameters}
|
||||
onPendingValuesChange={() => updateParametersDirtyFlag()}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import WarningTwoTone from "@ant-design/icons/WarningTwoTone";
|
||||
import TimeAgo from "@/components/TimeAgo";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import useAddToDashboardDialog from "../hooks/useAddToDashboardDialog";
|
||||
import useEmbedDialog from "../hooks/useEmbedDialog";
|
||||
import QueryControlDropdown from "@/components/EditVisualizationButton/QueryControlDropdown";
|
||||
@@ -42,6 +44,18 @@ export default function QueryExecutionMetadata({
|
||||
)}
|
||||
<span className="m-l-5 m-r-10">
|
||||
<span>
|
||||
{queryResultData.truncated === true && (
|
||||
<span className="m-r-5">
|
||||
<Tooltip
|
||||
title={
|
||||
"Result truncated to " +
|
||||
queryResultData.rows.length +
|
||||
" rows. Databricks may truncate query results that are unstably large."
|
||||
}>
|
||||
<WarningTwoTone twoToneColor="#FF9800" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
<strong>{queryResultData.rows.length}</strong> {pluralize("row", queryResultData.rows.length)}
|
||||
</span>
|
||||
<span className="m-l-5">
|
||||
|
||||
@@ -29,7 +29,7 @@ export function QuerySourceDropdown(props) {
|
||||
|
||||
QuerySourceDropdown.propTypes = {
|
||||
dataSources: PropTypes.any,
|
||||
value: PropTypes.string,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
disabled: PropTypes.bool,
|
||||
loading: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
|
||||
@@ -15,7 +15,7 @@ export function QuerySourceDropdownItem({ dataSource, children }) {
|
||||
QuerySourceDropdownItem.propTypes = {
|
||||
dataSource: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
type: PropTypes.string,
|
||||
}).isRequired,
|
||||
children: PropTypes.element,
|
||||
|
||||
@@ -26,7 +26,7 @@ function OrganizationSettings({ onError }) {
|
||||
{isLoading ? (
|
||||
<Skeleton.Button active />
|
||||
) : (
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
<Button type="primary" htmlType="submit" loading={isSaving} data-test="OrganizationSettingsSaveButton">
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -20,7 +20,9 @@ export default function FormatSettings(props) {
|
||||
onChange={value => onChange({ date_format: value })}
|
||||
data-test="DateFormatSelect">
|
||||
{clientConfig.dateFormatList.map(dateFormat => (
|
||||
<Select.Option key={dateFormat}>{dateFormat}</Select.Option>
|
||||
<Select.Option key={dateFormat} data-test={`DateFormatSelect:${dateFormat}`}>
|
||||
{dateFormat}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import recordEvent from "@/services/recordEvent";
|
||||
import OrgSettings from "@/services/organizationSettings";
|
||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
||||
import { updateClientConfig } from "@/services/auth";
|
||||
|
||||
export default function useOrganizationSettings({ onError }) {
|
||||
const [settings, setSettings] = useState({});
|
||||
@@ -49,6 +50,11 @@ export default function useOrganizationSettings({ onError }) {
|
||||
const settings = get(response, "settings");
|
||||
setSettings(settings);
|
||||
setCurrentValues({ ...settings });
|
||||
updateClientConfig({
|
||||
dateFormat: currentValues.date_format,
|
||||
timeFormat: currentValues.time_format,
|
||||
dateTimeFormat: `${currentValues.date_format} ${currentValues.time_format}`,
|
||||
});
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => setIsSaving(false));
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
@import "variables";
|
||||
@import "./variables";
|
||||
|
||||
@font-face {
|
||||
font-family: '@{icomoon-font-family}';
|
||||
src: url('@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm');
|
||||
src: url('@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm#iefix') format('embedded-opentype'),
|
||||
url('@{icomoon-font-path}/@{icomoon-font-family}.ttf?ehpufm') format('truetype'),
|
||||
url('@{icomoon-font-path}/@{icomoon-font-family}.woff?ehpufm') format('woff'),
|
||||
url('@{icomoon-font-path}/@{icomoon-font-family}.svg?ehpufm#@{icomoon-font-family}') format('svg');
|
||||
font-family: "@{icomoon-font-family}";
|
||||
src: url("@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm");
|
||||
src: url("@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm#iefix") format("embedded-opentype"),
|
||||
url("@{icomoon-font-path}/@{icomoon-font-family}.ttf?ehpufm") format("truetype"),
|
||||
url("@{icomoon-font-path}/@{icomoon-font-family}.woff?ehpufm") format("woff"),
|
||||
url("@{icomoon-font-path}/@{icomoon-font-family}.svg?ehpufm#@{icomoon-font-family}") format("svg");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
i.icon {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: '@{icomoon-font-family}' !important;
|
||||
font-family: "@{icomoon-font-family}" !important;
|
||||
speak: none;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
@@ -36,4 +36,3 @@ i.icon {
|
||||
content: @icon-flash;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ const AuthUrls = {
|
||||
Login: "login",
|
||||
};
|
||||
|
||||
export function updateClientConfig(newClientConfig) {
|
||||
extend(clientConfig, newClientConfig);
|
||||
}
|
||||
|
||||
function updateSession(sessionData) {
|
||||
logger("Updating session to be:", sessionData);
|
||||
extend(session, sessionData, { loaded: true });
|
||||
|
||||
@@ -168,6 +168,7 @@ const DashboardService = {
|
||||
delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse),
|
||||
query: params => axios.get("api/dashboards", { params }).then(transformResponse),
|
||||
recent: params => axios.get("api/dashboards/recent", { params }).then(transformResponse),
|
||||
myDashboards: params => axios.get("api/dashboards/my", { params }).then(transformResponse),
|
||||
favorites: params => axios.get("api/dashboards/favorites", { params }).then(transformResponse),
|
||||
favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`),
|
||||
unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`),
|
||||
@@ -208,12 +209,19 @@ Dashboard.prototype.getParametersDefs = function getParametersDefs() {
|
||||
});
|
||||
}
|
||||
});
|
||||
return _.values(
|
||||
const resultingGlobalParams = _.values(
|
||||
_.each(globalParams, param => {
|
||||
param.setValue(param.value); // apply global param value to all locals
|
||||
param.fromUrlParams(queryParams); // try to initialize from url (may do nothing)
|
||||
})
|
||||
);
|
||||
|
||||
// order dashboard params using paramOrder
|
||||
return _.sortBy(resultingGlobalParams, param =>
|
||||
_.includes(this.options.globalParamOrder, param.name)
|
||||
? _.indexOf(this.options.globalParamOrder, param.name)
|
||||
: _.size(this.options.globalParamOrder)
|
||||
);
|
||||
};
|
||||
|
||||
Dashboard.prototype.addWidget = function addWidget(textOrVisualization, options = {}) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isNull, isObject, isFunction, isUndefined, isEqual, has, omit, isArray, each } from "lodash";
|
||||
import { toHuman } from "@/lib/utils";
|
||||
|
||||
class Parameter {
|
||||
constructor(parameter, parentQueryId) {
|
||||
@@ -45,10 +44,6 @@ class Parameter {
|
||||
return this.$$value;
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return this.title || toHuman(this.name);
|
||||
}
|
||||
|
||||
isEmptyValue(value) {
|
||||
return isNull(this.normalizeValue(value));
|
||||
}
|
||||
|
||||
@@ -1,70 +1,15 @@
|
||||
import {
|
||||
isNull,
|
||||
isUndefined,
|
||||
isArray,
|
||||
isEmpty,
|
||||
get,
|
||||
map,
|
||||
join,
|
||||
has,
|
||||
toString,
|
||||
findKey,
|
||||
mapValues,
|
||||
pickBy,
|
||||
filter,
|
||||
omit,
|
||||
} from "lodash";
|
||||
import { isNull, isUndefined, isArray, isEmpty, get, map, join, has } from "lodash";
|
||||
import { Query } from "@/services/query";
|
||||
import QueryResult from "@/services/query-result";
|
||||
import Parameter from "./Parameter";
|
||||
|
||||
function mapQueryResultToDropdownOptions(options) {
|
||||
return map(options, ({ label, name, value }) => ({ label: label || name, value: toString(value) }));
|
||||
}
|
||||
|
||||
export const QueryBasedParameterMappingType = {
|
||||
DROPDOWN_SEARCH: "search",
|
||||
STATIC: "static",
|
||||
UNDEFINED: "undefined",
|
||||
};
|
||||
|
||||
function extractOptionLabelsFromValues(values) {
|
||||
if (!isArray(values)) {
|
||||
values = [values];
|
||||
}
|
||||
|
||||
const optionLabels = {};
|
||||
values.forEach(val => {
|
||||
if (has(val, "label") && has(val, "value")) {
|
||||
optionLabels[val.value] = val.label;
|
||||
}
|
||||
});
|
||||
|
||||
return optionLabels;
|
||||
}
|
||||
|
||||
class QueryBasedDropdownParameter extends Parameter {
|
||||
constructor(parameter, parentQueryId) {
|
||||
super(parameter, parentQueryId);
|
||||
this.queryId = parameter.queryId;
|
||||
this.multiValuesOptions = parameter.multiValuesOptions;
|
||||
this.parameterMapping = parameter.parameterMapping;
|
||||
this.$$optionLabels = extractOptionLabelsFromValues(parameter.value);
|
||||
this.setValue(parameter.value);
|
||||
}
|
||||
|
||||
get searchColumn() {
|
||||
return findKey(this.parameterMapping, { mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH });
|
||||
}
|
||||
|
||||
get staticParams() {
|
||||
const staticParams = pickBy(
|
||||
this.parameterMapping,
|
||||
mapping => mapping.mappingType === QueryBasedParameterMappingType.STATIC
|
||||
);
|
||||
return mapValues(staticParams, value => value.staticValue);
|
||||
}
|
||||
|
||||
normalizeValue(value) {
|
||||
if (isUndefined(value) || isNull(value) || (isArray(value) && isEmpty(value))) {
|
||||
return null;
|
||||
@@ -75,48 +20,24 @@ class QueryBasedDropdownParameter extends Parameter {
|
||||
} else {
|
||||
value = isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
if (this.searchColumn) {
|
||||
value = this._getLabeledValue(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
setValue(value) {
|
||||
if (this.searchColumn) {
|
||||
value = this._getLabeledValue(value);
|
||||
}
|
||||
|
||||
return super.setValue(value);
|
||||
}
|
||||
|
||||
getExecutionValue(extra = {}) {
|
||||
const { joinListValues } = extra;
|
||||
let executionValue = this.value;
|
||||
if (isArray(executionValue)) {
|
||||
executionValue = map(executionValue, value => get(value, "value", value));
|
||||
|
||||
if (joinListValues) {
|
||||
if (joinListValues && isArray(this.value)) {
|
||||
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);
|
||||
const parameterValues = map(this.value, v => `${prefix}${v}${suffix}`);
|
||||
return join(parameterValues, separator);
|
||||
}
|
||||
return executionValue;
|
||||
}
|
||||
|
||||
executionValue = get(executionValue, "value", executionValue);
|
||||
return executionValue;
|
||||
return this.value;
|
||||
}
|
||||
|
||||
toUrlParams() {
|
||||
const prefix = this.urlPrefix;
|
||||
|
||||
if (this.searchColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
let urlParam = this.value;
|
||||
if (this.multiValuesOptions && isArray(this.value)) {
|
||||
urlParam = JSON.stringify(this.value);
|
||||
@@ -130,80 +51,28 @@ class QueryBasedDropdownParameter extends Parameter {
|
||||
fromUrlParams(query) {
|
||||
const prefix = this.urlPrefix;
|
||||
const key = `${prefix}${this.name}`;
|
||||
|
||||
if (this.searchColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (has(query, key)) {
|
||||
const queryKey = query[key];
|
||||
if (this.multiValuesOptions) {
|
||||
try {
|
||||
const valueFromJson = JSON.parse(queryKey);
|
||||
this.setValue(isArray(valueFromJson) ? valueFromJson : queryKey);
|
||||
const valueFromJson = JSON.parse(query[key]);
|
||||
this.setValue(isArray(valueFromJson) ? valueFromJson : query[key]);
|
||||
} catch (e) {
|
||||
this.setValue(queryKey);
|
||||
this.setValue(query[key]);
|
||||
}
|
||||
} else {
|
||||
this.setValue(queryKey);
|
||||
this.setValue(query[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_saveLabeledValuesFromOptions(options) {
|
||||
this.$$optionLabels = { ...this.$$optionLabels, ...extractOptionLabelsFromValues(options) };
|
||||
return options;
|
||||
loadDropdownValues() {
|
||||
if (this.parentQueryId) {
|
||||
return Query.associatedDropdown({ queryId: this.parentQueryId, dropdownQueryId: 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"]);
|
||||
return Query.asDropdown({ id: this.queryId }).catch(Promise.resolve([]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,18 +31,6 @@ describe("QueryBasedDropdownParameter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getExecutionValue", () => {
|
||||
test("returns value when stored value doesn't contain its label", () => {
|
||||
param.setValue("test");
|
||||
expect(param.getExecutionValue()).toBe("test");
|
||||
});
|
||||
|
||||
test("returns value from object when stored value contains its label", () => {
|
||||
param.setValue({ label: "Test Label", value: "test" });
|
||||
expect(param.getExecutionValue()).toBe("test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multi-valued", () => {
|
||||
beforeAll(() => {
|
||||
multiValuesOptions = { prefix: '"', suffix: '"', separator: "," };
|
||||
@@ -56,19 +44,6 @@ describe("QueryBasedDropdownParameter", () => {
|
||||
});
|
||||
|
||||
describe("getExecutionValue", () => {
|
||||
test("returns value when stored value doesn't contain its label", () => {
|
||||
param.setValue(["test1", "test2"]);
|
||||
expect(param.getExecutionValue()).toEqual(["test1", "test2"]);
|
||||
});
|
||||
|
||||
test("returns value from object when stored value contains its label", () => {
|
||||
param.setValue([
|
||||
{ label: "Test Label 1", value: "test1" },
|
||||
{ label: "Test Label 2", value: "test2" },
|
||||
]);
|
||||
expect(param.getExecutionValue()).toEqual(["test1", "test2"]);
|
||||
});
|
||||
|
||||
test("joins values when joinListValues is truthy", () => {
|
||||
param.setValue(["value1", "value3"]);
|
||||
const executionValue = param.getExecutionValue({ joinListValues: true });
|
||||
|
||||
@@ -271,6 +271,10 @@ class QueryResult {
|
||||
return this.getColumnNames().map(col => getColumnFriendlyName(col));
|
||||
}
|
||||
|
||||
getTruncated() {
|
||||
return this.query_result.data ? this.query_result.data.truncated : null;
|
||||
}
|
||||
|
||||
getFilters() {
|
||||
if (!this.getColumns()) {
|
||||
return [];
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import moment from "moment";
|
||||
import { axios } from "@/services/axios";
|
||||
import { each, pick, extend, isObject, truncate, keys, difference, filter, map, merge } from "lodash";
|
||||
import {
|
||||
each,
|
||||
pick,
|
||||
extend,
|
||||
isObject,
|
||||
truncate,
|
||||
keys,
|
||||
difference,
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
sortBy,
|
||||
indexOf,
|
||||
size,
|
||||
includes,
|
||||
} from "lodash";
|
||||
import location from "@/services/location";
|
||||
import { cloneParameter } from "@/services/parameters";
|
||||
import dashboardGridOptions from "@/config/dashboard-grid-options";
|
||||
@@ -207,7 +222,7 @@ class Widget {
|
||||
const queryParams = location.search;
|
||||
|
||||
const localTypes = [Widget.MappingType.WidgetLevel, Widget.MappingType.StaticValue];
|
||||
return map(
|
||||
const localParameters = map(
|
||||
filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0),
|
||||
param => {
|
||||
const mapping = mappings[param.name];
|
||||
@@ -223,6 +238,13 @@ class Widget {
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
// order widget params using paramOrder
|
||||
return sortBy(localParameters, param =>
|
||||
includes(this.options.paramOrder, param.name)
|
||||
? indexOf(this.options.paramOrder, param.name)
|
||||
: size(this.options.paramOrder)
|
||||
);
|
||||
}
|
||||
|
||||
getParameterMappings() {
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
164
client/cypress/integration/dashboard/parameter_spec.js
Normal file
164
client/cypress/integration/dashboard/parameter_spec.js
Normal file
@@ -0,0 +1,164 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,11 @@
|
||||
import { dragParam } from "../../support/parameters";
|
||||
|
||||
function openAndSearchAntdDropdown(testId, paramOption) {
|
||||
cy.getByTestId(testId)
|
||||
.find(".ant-select-selection-search-input")
|
||||
.type(paramOption, { force: true });
|
||||
}
|
||||
|
||||
describe("Parameter", () => {
|
||||
const expectDirtyStateChange = edit => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
@@ -107,11 +115,13 @@ describe("Parameter", () => {
|
||||
});
|
||||
|
||||
it("updates the results after selecting a value", () => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find(".ant-select")
|
||||
.click();
|
||||
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
|
||||
|
||||
cy.contains(".ant-select-item-option", "value2").click();
|
||||
// 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
|
||||
@@ -219,6 +229,22 @@ describe("Parameter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the results after selecting a value", () => {
|
||||
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
|
||||
|
||||
// only the filtered option should be on the DOM
|
||||
cy.get(".ant-select-item-option")
|
||||
.should("have.length", 1)
|
||||
.and("contain", "value2")
|
||||
.click();
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
// ensure that query is being executed
|
||||
cy.getByTestId("QueryExecutionStatus").should("exist");
|
||||
|
||||
cy.getByTestId("TableVisualization").should("contain", "2");
|
||||
});
|
||||
|
||||
it("supports multi-selection", () => {
|
||||
cy.clickThrough(`
|
||||
ParameterSettings-test-parameter
|
||||
@@ -575,16 +601,6 @@ describe("Parameter", () => {
|
||||
cy.get("body").type("{alt}D"); // hide schema browser
|
||||
});
|
||||
|
||||
const dragParam = (paramName, offsetLeft, offsetTop) => {
|
||||
cy.getByTestId(`DragHandle-${paramName}`)
|
||||
.trigger("mouseover")
|
||||
.trigger("mousedown");
|
||||
|
||||
cy.get(".parameter-dragged .drag-handle")
|
||||
.trigger("mousemove", offsetLeft, offsetTop, { force: true })
|
||||
.trigger("mouseup", { force: true });
|
||||
};
|
||||
|
||||
it("is possible to rearrange parameters", function() {
|
||||
cy.server();
|
||||
cy.route("POST", "**/api/queries/*").as("QuerySave");
|
||||
|
||||
@@ -6,10 +6,40 @@ describe("Settings", () => {
|
||||
|
||||
it("renders the page and takes a screenshot", () => {
|
||||
cy.getByTestId("OrganizationSettings").within(() => {
|
||||
cy.getByTestId("DateFormatSelect").should("contain", "DD/MM/YY");
|
||||
cy.getByTestId("TimeFormatSelect").should("contain", "HH:mm");
|
||||
});
|
||||
|
||||
cy.percySnapshot("Organization Settings");
|
||||
});
|
||||
|
||||
it("can set date format setting", () => {
|
||||
cy.getByTestId("DateFormatSelect").click();
|
||||
cy.getByTestId("DateFormatSelect:YYYY-MM-DD").click();
|
||||
cy.getByTestId("OrganizationSettingsSaveButton").click();
|
||||
|
||||
cy.createQuery({
|
||||
name: "test date format",
|
||||
query: "SELECT NOW()",
|
||||
}).then(({ id: queryId }) => {
|
||||
cy.visit(`/queries/${queryId}`);
|
||||
cy.findByText("Refresh Now").click();
|
||||
|
||||
// "created at" field is formatted with the date format.
|
||||
cy.getByTestId("TableVisualization")
|
||||
.findAllByText(/\d{4}-\d{2}-\d{2}/)
|
||||
.should("exist");
|
||||
|
||||
// set to a different format and expect a different result in the table
|
||||
cy.visit("/settings/general");
|
||||
cy.getByTestId("DateFormatSelect").click();
|
||||
cy.getByTestId("DateFormatSelect:MM/DD/YY").click();
|
||||
cy.getByTestId("OrganizationSettingsSaveButton").click();
|
||||
|
||||
cy.visit(`/queries/${queryId}`);
|
||||
|
||||
cy.getByTestId("TableVisualization")
|
||||
.findAllByText(/\d{2}\/\d{2}\/\d{2}/)
|
||||
.should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
110
client/cypress/integration/visualizations/chart_spec.js
Normal file
110
client/cypress/integration/visualizations/chart_spec.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/* 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");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import "@percy/cypress"; // eslint-disable-line import/no-extraneous-dependencies, import/no-unresolved
|
||||
|
||||
import "@testing-library/cypress/add-commands";
|
||||
|
||||
const { each } = Cypress._;
|
||||
|
||||
Cypress.Commands.add("login", (email = "admin@redash.io", password = "password") => {
|
||||
|
||||
13
client/cypress/support/parameters.js
Normal file
13
client/cypress/support/parameters.js
Normal file
@@ -0,0 +1,13 @@
|
||||
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]));
|
||||
}
|
||||
100
client/cypress/support/visualizations/chart.js
Normal file
100
client/cypress/support/visualizations/chart.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["cypress","@percy/cypress","@testing-library/cypress"]
|
||||
"types": ["cypress", "@percy/cypress", "@testing-library/cypress"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -15,21 +15,14 @@
|
||||
"jsx": "react",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUnusedLocals": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["./app/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"app/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
]
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["app/**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
|
||||
28
migrations/versions/0ec979123ba4_.py
Normal file
28
migrations/versions/0ec979123ba4_.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""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 ###
|
||||
24
migrations/versions/89bc7873a3e0_fix_multiple_heads.py
Normal file
24
migrations/versions/89bc7873a3e0_fix_multiple_heads.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""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
|
||||
@@ -0,0 +1,64 @@
|
||||
"""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
|
||||
2462
package-lock.json
generated
2462
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -48,7 +48,7 @@
|
||||
"@redash/viz": "file:viz-lib",
|
||||
"ace-builds": "^1.4.12",
|
||||
"antd": "^4.4.3",
|
||||
"axios": "^0.19.0",
|
||||
"axios": "^0.21.1",
|
||||
"axios-auth-refresh": "^3.0.0",
|
||||
"bootstrap": "^3.3.7",
|
||||
"classnames": "^2.2.6",
|
||||
@@ -67,9 +67,9 @@
|
||||
"path-to-regexp": "^3.1.0",
|
||||
"prop-types": "^15.6.1",
|
||||
"query-string": "^6.9.0",
|
||||
"react": "^16.13.1",
|
||||
"react": "^16.14.0",
|
||||
"react-ace": "^9.1.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-dom": "^16.14.0",
|
||||
"react-grid-layout": "^0.18.2",
|
||||
"react-resizable": "^1.10.1",
|
||||
"react-virtualized": "^9.21.2",
|
||||
@@ -89,12 +89,14 @@
|
||||
"@cypress/code-coverage": "^3.8.1",
|
||||
"@percy/agent": "0.24.3",
|
||||
"@percy/cypress": "^2.3.2",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
|
||||
"@testing-library/cypress": "^7.0.2",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/hoist-non-react-statics": "^3.3.1",
|
||||
"@types/lodash": "^4.14.157",
|
||||
"@types/prop-types": "^15.7.3",
|
||||
"@types/react": "^16.9.41",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react": "^16.14.2",
|
||||
"@types/react-dom": "^16.9.10",
|
||||
"@types/sql-formatter": "^2.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^2.10.0",
|
||||
"@typescript-eslint/parser": "^2.10.0",
|
||||
@@ -137,16 +139,18 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^1.19.1",
|
||||
"raw-loader": "^0.5.1",
|
||||
"react-refresh": "^0.9.0",
|
||||
"react-test-renderer": "^16.5.2",
|
||||
"request": "^2.88.0",
|
||||
"request-cookies": "^1.1.0",
|
||||
"typescript": "^3.9.6",
|
||||
"style-loader": "^2.0.0",
|
||||
"typescript": "^4.1.2",
|
||||
"url-loader": "^1.1.2",
|
||||
"webpack": "^4.20.2",
|
||||
"webpack": "^4.44.2",
|
||||
"webpack-build-notifier": "^0.1.30",
|
||||
"webpack-bundle-analyzer": "^2.11.1",
|
||||
"webpack-cli": "^3.1.2",
|
||||
"webpack-dev-server": "^3.1.9",
|
||||
"webpack-dev-server": "^3.11.0",
|
||||
"webpack-manifest-plugin": "^2.0.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.sql import select
|
||||
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
|
||||
|
||||
from redash import settings
|
||||
from redash.models.base import Column
|
||||
from redash.models.base import Column, key_type
|
||||
from redash.models.types import EncryptedConfiguration
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
|
||||
@@ -86,10 +86,11 @@ def reencrypt(old_secret, new_secret, show_sql):
|
||||
logging.basicConfig()
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
|
||||
|
||||
def _reencrypt_for_table(table_name, orm_name):
|
||||
table_for_select = sqlalchemy.Table(
|
||||
"data_sources",
|
||||
table_name,
|
||||
sqlalchemy.MetaData(),
|
||||
Column("id", db.Integer, primary_key=True),
|
||||
Column("id", key_type(orm_name), primary_key=True),
|
||||
Column(
|
||||
"encrypted_options",
|
||||
ConfigurationContainer.as_mutable(
|
||||
@@ -98,9 +99,9 @@ def reencrypt(old_secret, new_secret, show_sql):
|
||||
),
|
||||
)
|
||||
table_for_update = sqlalchemy.Table(
|
||||
"data_sources",
|
||||
table_name,
|
||||
sqlalchemy.MetaData(),
|
||||
Column("id", db.Integer, primary_key=True),
|
||||
Column("id", key_type(orm_name), primary_key=True),
|
||||
Column(
|
||||
"encrypted_options",
|
||||
ConfigurationContainer.as_mutable(
|
||||
@@ -110,12 +111,15 @@ def reencrypt(old_secret, new_secret, show_sql):
|
||||
)
|
||||
|
||||
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"]
|
||||
selected_items = db.session.execute(select([table_for_select]))
|
||||
for item in selected_items:
|
||||
stmt = update.where(table_for_update.c.id == item["id"]).values(
|
||||
encrypted_options=item["encrypted_options"]
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
|
||||
data_sources.close()
|
||||
selected_items.close()
|
||||
db.session.commit()
|
||||
|
||||
_reencrypt_for_table("data_sources", "DataSource")
|
||||
_reencrypt_for_table("notification_destinations", "NotificationDestination")
|
||||
|
||||
@@ -50,30 +50,22 @@ def worker(queues):
|
||||
|
||||
|
||||
class WorkerHealthcheck(base.BaseCheck):
|
||||
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
|
||||
NAME = "RQ Worker Healthcheck"
|
||||
|
||||
def __call__(self, process_spec):
|
||||
pid = process_spec['pid']
|
||||
if not self.time_to_check(pid):
|
||||
return True
|
||||
|
||||
pid = process_spec["pid"]
|
||||
all_workers = Worker.all(connection=rq_redis_connection)
|
||||
worker = [w for w in all_workers if w.hostname == socket.gethostname().encode() and
|
||||
w.pid == pid].pop()
|
||||
workers = [
|
||||
w
|
||||
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
|
||||
|
||||
@@ -85,12 +77,19 @@ class WorkerHealthcheck(base.BaseCheck):
|
||||
|
||||
is_healthy = is_busy or seen_lately or has_nothing_to_do
|
||||
|
||||
self._log("Worker %s healthcheck: Is busy? %s. "
|
||||
self._log(
|
||||
"Worker %s healthcheck: Is busy? %s. "
|
||||
"Seen lately? %s (%d seconds ago). "
|
||||
"Has nothing to do? %s (%d jobs in watched queues). "
|
||||
"==> Is healthy? %s",
|
||||
worker.key, is_busy, seen_lately, time_since_seen.seconds,
|
||||
has_nothing_to_do, total_jobs_in_watched_queues, is_healthy)
|
||||
worker.key,
|
||||
is_busy,
|
||||
seen_lately,
|
||||
time_since_seen.seconds,
|
||||
has_nothing_to_do,
|
||||
total_jobs_in_watched_queues,
|
||||
is_healthy,
|
||||
)
|
||||
|
||||
return is_healthy
|
||||
|
||||
@@ -98,4 +97,5 @@ class WorkerHealthcheck(base.BaseCheck):
|
||||
@manager.command()
|
||||
def healthcheck():
|
||||
return check_runner.CheckRunner(
|
||||
'worker_healthcheck', 'worker', None, [(WorkerHealthcheck, {})]).run()
|
||||
"worker_healthcheck", "worker", None, [(WorkerHealthcheck, {})]
|
||||
).run()
|
||||
|
||||
@@ -22,6 +22,7 @@ class ChatWork(BaseDestination):
|
||||
"title": "Message Template",
|
||||
},
|
||||
},
|
||||
"secret": ["api_token"],
|
||||
"required": ["message_template", "api_token", "room_id"],
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class HangoutsChat(BaseDestination):
|
||||
"title": "Icon URL (32x32 or multiple, png format)",
|
||||
},
|
||||
},
|
||||
"secret": ["url"],
|
||||
"required": ["url"],
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class HipChat(BaseDestination):
|
||||
"title": "HipChat Notification URL (get it from the Integrations page)",
|
||||
}
|
||||
},
|
||||
"secret": ["url"],
|
||||
"required": ["url"],
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class Mattermost(BaseDestination):
|
||||
"icon_url": {"type": "string", "title": "Icon (URL)"},
|
||||
"channel": {"type": "string", "title": "Channel"},
|
||||
},
|
||||
"secret": "url"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -32,6 +32,7 @@ class PagerDuty(BaseDestination):
|
||||
"title": "Description for the event, defaults to alert name",
|
||||
},
|
||||
},
|
||||
"secret": ["integration_key"],
|
||||
"required": ["integration_key"],
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ class Slack(BaseDestination):
|
||||
"icon_url": {"type": "string", "title": "Icon (URL)"},
|
||||
"channel": {"type": "string", "title": "Channel"},
|
||||
},
|
||||
"secret": ["url"]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -18,7 +18,7 @@ class Webhook(BaseDestination):
|
||||
"password": {"type": "string"},
|
||||
},
|
||||
"required": ["url"],
|
||||
"secret": ["password"],
|
||||
"secret": ["password", "url"],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -11,6 +11,7 @@ from redash.handlers.alerts import (
|
||||
)
|
||||
from redash.handlers.base import org_scoped_rule
|
||||
from redash.handlers.dashboards import (
|
||||
MyDashboardsResource,
|
||||
DashboardFavoriteListResource,
|
||||
DashboardListResource,
|
||||
DashboardResource,
|
||||
@@ -209,6 +210,8 @@ api.add_org_resource(
|
||||
endpoint="dashboard_favorite",
|
||||
)
|
||||
|
||||
api.add_org_resource(MyDashboardsResource, "/api/dashboards/my", endpoint="my_dashboards")
|
||||
|
||||
api.add_org_resource(QueryTagsResource, "/api/queries/tags", endpoint="query_tags")
|
||||
api.add_org_resource(
|
||||
DashboardTagsResource, "/api/dashboards/tags", endpoint="dashboard_tags"
|
||||
|
||||
@@ -113,6 +113,43 @@ class DashboardListResource(BaseResource):
|
||||
return DashboardSerializer(dashboard).serialize()
|
||||
|
||||
|
||||
class MyDashboardsResource(BaseResource):
|
||||
@require_permission("list_dashboards")
|
||||
def get(self):
|
||||
"""
|
||||
Retrieve a list of dashboards created by the current user.
|
||||
|
||||
:qparam number page_size: Number of dashboards to return per page
|
||||
:qparam number page: Page number to retrieve
|
||||
:qparam number order: Name of column to order by
|
||||
:qparam number search: Full text search term
|
||||
|
||||
Responds with an array of :ref:`dashboard <dashboard-response-label>`
|
||||
objects.
|
||||
"""
|
||||
search_term = request.args.get("q", "")
|
||||
if search_term:
|
||||
results = models.Dashboard.search_by_user(search_term, self.current_user)
|
||||
else:
|
||||
results = models.Dashboard.by_user(self.current_user)
|
||||
|
||||
results = filter_by_tags(results, models.Dashboard.tags)
|
||||
|
||||
# order results according to passed order parameter,
|
||||
# special-casing search queries where the database
|
||||
# provides an order by search rank
|
||||
ordered_results = order_results(results, fallback=not bool(search_term))
|
||||
|
||||
page = request.args.get("page", 1, type=int)
|
||||
page_size = request.args.get("page_size", 25, type=int)
|
||||
return paginate(
|
||||
ordered_results,
|
||||
page,
|
||||
page_size,
|
||||
DashboardSerializer
|
||||
)
|
||||
|
||||
|
||||
class DashboardResource(BaseResource):
|
||||
@require_permission("list_dashboards")
|
||||
def get(self, dashboard_id=None):
|
||||
@@ -135,6 +172,7 @@ class DashboardResource(BaseResource):
|
||||
:>json boolean is_draft: Whether this dashboard is a draft or not.
|
||||
:>json array layout: Array of arrays containing widget IDs, corresponding to the rows and columns the widgets are displayed in
|
||||
:>json array widgets: Array of arrays containing :ref:`widget <widget-response-label>` data
|
||||
:>json object options: Dashboard options
|
||||
|
||||
.. _widget-response-label:
|
||||
|
||||
@@ -205,6 +243,7 @@ class DashboardResource(BaseResource):
|
||||
"is_draft",
|
||||
"is_archived",
|
||||
"dashboard_filters_enabled",
|
||||
"options",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ class QueryResultDropdownResource(BaseResource):
|
||||
)
|
||||
require_access(query.data_source, current_user, view_only)
|
||||
try:
|
||||
return dropdown_values(query, self.current_org)
|
||||
return dropdown_values(query_id, self.current_org)
|
||||
except QueryDetachedFromDataSourceError as e:
|
||||
abort(400, message=str(e))
|
||||
|
||||
@@ -224,14 +224,13 @@ class QueryDropdownsResource(BaseResource):
|
||||
related_queries_ids = [
|
||||
p["queryId"] for p in query.parameters if p["type"] == "query"
|
||||
]
|
||||
if int(dropdown_query_id) not in related_queries_ids:
|
||||
dropdown_query = get_object_or_404(
|
||||
models.Query.get_by_id_and_org, dropdown_query_id, self.current_org
|
||||
)
|
||||
|
||||
if int(dropdown_query_id) not in related_queries_ids:
|
||||
require_access(dropdown_query.data_source, current_user, view_only)
|
||||
|
||||
return dropdown_values(dropdown_query, self.current_org)
|
||||
return dropdown_values(dropdown_query_id, self.current_org)
|
||||
|
||||
|
||||
class QueryResultResource(BaseResource):
|
||||
|
||||
@@ -1099,6 +1099,9 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
|
||||
tags = Column(
|
||||
"tags", MutableList.as_mutable(postgresql.ARRAY(db.Unicode)), nullable=True
|
||||
)
|
||||
options = Column(
|
||||
MutableDict.as_mutable(postgresql.JSON), server_default="{}", default={}
|
||||
)
|
||||
|
||||
__tablename__ = "dashboards"
|
||||
__mapper_args__ = {"version_id_col": version}
|
||||
@@ -1132,7 +1135,6 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
|
||||
),
|
||||
Dashboard.org == org,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
query = query.filter(
|
||||
@@ -1148,6 +1150,10 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
|
||||
cls.name.ilike("%{}%".format(search_term))
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def search_by_user(cls, term, user, limit=None):
|
||||
return cls.by_user(user).filter(cls.name.ilike("%{}%".format(term))).limit(limit)
|
||||
|
||||
@classmethod
|
||||
def all_tags(cls, org, user):
|
||||
dashboards = cls.all(org, user.group_ids, user.id)
|
||||
@@ -1177,6 +1183,10 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
|
||||
)
|
||||
).filter(Favorite.user_id == user.id)
|
||||
|
||||
@classmethod
|
||||
def by_user(cls, user):
|
||||
return cls.all(user.org, user.group_ids, user.id).filter(Dashboard.user == user)
|
||||
|
||||
@classmethod
|
||||
def get_by_slug_and_org(cls, slug, org):
|
||||
return cls.query.filter(cls.slug == slug, cls.org == org).one()
|
||||
@@ -1351,7 +1361,14 @@ class NotificationDestination(BelongsToOrgMixin, db.Model):
|
||||
user = db.relationship(User, backref="notification_destinations")
|
||||
name = Column(db.String(255))
|
||||
type = Column(db.String(255))
|
||||
options = Column(ConfigurationContainer.as_mutable(Configuration))
|
||||
options = Column(
|
||||
"encrypted_options",
|
||||
ConfigurationContainer.as_mutable(
|
||||
EncryptedConfiguration(
|
||||
db.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine
|
||||
)
|
||||
),
|
||||
)
|
||||
created_at = Column(db.DateTime(True), default=db.func.now())
|
||||
|
||||
__tablename__ = "notification_destinations"
|
||||
|
||||
@@ -5,7 +5,6 @@ from redash.utils import mustache_render, json_loads
|
||||
from redash.permissions import require_access, view_only
|
||||
from funcy import distinct
|
||||
from dateutil.parser import parse
|
||||
from redash import models
|
||||
|
||||
|
||||
def _pluck_name_and_value(default_column, row):
|
||||
@@ -16,18 +15,22 @@ def _pluck_name_and_value(default_column, row):
|
||||
return {"name": row[name_column], "value": str(row[value_column])}
|
||||
|
||||
|
||||
def _load_result(query, org):
|
||||
def _load_result(query_id, org):
|
||||
from redash import models
|
||||
|
||||
query = models.Query.get_by_id_and_org(query_id, org)
|
||||
|
||||
if query.data_source:
|
||||
query_result = models.QueryResult.get_by_id_and_org(
|
||||
query.latest_query_data_id, org
|
||||
)
|
||||
return query_result.data
|
||||
else:
|
||||
raise QueryDetachedFromDataSourceError(query.id)
|
||||
raise QueryDetachedFromDataSourceError(query_id)
|
||||
|
||||
|
||||
def dropdown_values(query, org):
|
||||
data = _load_result(query, org)
|
||||
def dropdown_values(query_id, org):
|
||||
data = _load_result(query_id, org)
|
||||
first_column = data["columns"][0]["name"]
|
||||
pluck = partial(_pluck_name_and_value, first_column)
|
||||
return list(map(pluck, data["rows"]))
|
||||
@@ -152,12 +155,6 @@ class ParameterizedQuery(object):
|
||||
query_id = definition.get("queryId")
|
||||
allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict)
|
||||
|
||||
if definition["type"] == "query":
|
||||
try:
|
||||
query = models.Query.get_by_id_and_org(query_id, self.org)
|
||||
except (models.NoResultFound):
|
||||
return False
|
||||
|
||||
if isinstance(enum_options, str):
|
||||
enum_options = enum_options.split("\n")
|
||||
|
||||
@@ -169,11 +166,9 @@ class ParameterizedQuery(object):
|
||||
),
|
||||
"query": lambda value: _is_value_within_options(
|
||||
value,
|
||||
[v["value"] for v in dropdown_values(query, self.org)],
|
||||
[v["value"] for v in dropdown_values(query_id, self.org)],
|
||||
allow_multiple_values,
|
||||
)
|
||||
if not query.parameters
|
||||
else True,
|
||||
),
|
||||
"date": _is_date,
|
||||
"datetime-local": _is_date,
|
||||
"datetime-with-seconds": _is_date,
|
||||
@@ -188,18 +183,8 @@ class ParameterizedQuery(object):
|
||||
|
||||
@property
|
||||
def is_safe(self):
|
||||
for param in self.schema:
|
||||
if param["type"] == "text":
|
||||
return False
|
||||
if param["type"] == "query":
|
||||
try:
|
||||
query = models.Query.get_by_id_and_org(
|
||||
param.get("queryId"), self.org
|
||||
)
|
||||
return not query.parameters
|
||||
except (models.NoResultFound):
|
||||
return True
|
||||
return True
|
||||
text_parameters = [param for param in self.schema if param["type"] == "text"]
|
||||
return not any(text_parameters)
|
||||
|
||||
@property
|
||||
def missing_params(self):
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import sqlparse
|
||||
from redash.query_runner import (
|
||||
NotSupported,
|
||||
@@ -11,8 +13,9 @@ from redash.query_runner import (
|
||||
TYPE_INTEGER,
|
||||
TYPE_FLOAT,
|
||||
)
|
||||
from redash.settings import cast_int_or_default
|
||||
from redash.utils import json_dumps, json_loads
|
||||
from redash import __version__
|
||||
from redash import __version__, settings, statsd_client
|
||||
|
||||
try:
|
||||
import pyodbc
|
||||
@@ -30,6 +33,9 @@ TYPES_MAP = {
|
||||
float: TYPE_FLOAT,
|
||||
}
|
||||
|
||||
ROW_LIMIT = cast_int_or_default(os.environ.get("DATABRICKS_ROW_LIMIT"), 20000)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _build_odbc_connection_string(**kwargs):
|
||||
return ";".join([f"{k}={v}" for k, v in kwargs.items()])
|
||||
@@ -40,8 +46,10 @@ def split_sql_statements(query):
|
||||
idx = len(stmt.tokens) - 1
|
||||
while idx >= 0:
|
||||
tok = stmt.tokens[idx]
|
||||
if tok.is_whitespace or sqlparse.utils.imt(tok, i=sqlparse.sql.Comment, t=sqlparse.tokens.Comment):
|
||||
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, ' ')
|
||||
if tok.is_whitespace or sqlparse.utils.imt(
|
||||
tok, i=sqlparse.sql.Comment, t=sqlparse.tokens.Comment
|
||||
):
|
||||
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, " ")
|
||||
else:
|
||||
break
|
||||
idx -= 1
|
||||
@@ -53,8 +61,13 @@ def split_sql_statements(query):
|
||||
tok = stmt.tokens[idx]
|
||||
# we expect that trailing comments already are removed
|
||||
if not tok.is_whitespace:
|
||||
if sqlparse.utils.imt(tok, t=sqlparse.tokens.Punctuation) and tok.value == ";":
|
||||
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, ' ')
|
||||
if (
|
||||
sqlparse.utils.imt(tok, t=sqlparse.tokens.Punctuation)
|
||||
and tok.value == ";"
|
||||
):
|
||||
stmt.tokens[idx] = sqlparse.sql.Token(
|
||||
sqlparse.tokens.Whitespace, " "
|
||||
)
|
||||
break
|
||||
idx -= 1
|
||||
return stmt
|
||||
@@ -74,7 +87,11 @@ def split_sql_statements(query):
|
||||
result = [stmt for stmt in stack.run(query)]
|
||||
result = [strip_trailing_comments(stmt) for stmt in result]
|
||||
result = [strip_trailing_semicolon(stmt) for stmt in result]
|
||||
result = [sqlparse.text_type(stmt).strip() for stmt in result if not is_empty_statement(stmt)]
|
||||
result = [
|
||||
sqlparse.text_type(stmt).strip()
|
||||
for stmt in result
|
||||
if not is_empty_statement(stmt)
|
||||
]
|
||||
|
||||
if len(result) > 0:
|
||||
return result
|
||||
@@ -147,7 +164,7 @@ class Databricks(BaseSQLQueryRunner):
|
||||
cursor.execute(stmt)
|
||||
|
||||
if cursor.description is not None:
|
||||
data = cursor.fetchall()
|
||||
result_set = cursor.fetchmany(ROW_LIMIT)
|
||||
columns = self.fetch_columns(
|
||||
[
|
||||
(i[0], TYPES_MAP.get(i[1], TYPE_STRING))
|
||||
@@ -157,10 +174,18 @@ class Databricks(BaseSQLQueryRunner):
|
||||
|
||||
rows = [
|
||||
dict(zip((column["name"] for column in columns), row))
|
||||
for row in data
|
||||
for row in result_set
|
||||
]
|
||||
|
||||
data = {"columns": columns, "rows": rows}
|
||||
|
||||
if (
|
||||
len(result_set) >= ROW_LIMIT
|
||||
and cursor.fetchone() is not None
|
||||
):
|
||||
logger.warning("Truncated result set.")
|
||||
statsd_client.incr("redash.query_runner.databricks.truncated")
|
||||
data["truncated"] = True
|
||||
json_data = json_dumps(data)
|
||||
error = None
|
||||
else:
|
||||
|
||||
@@ -133,6 +133,8 @@ class MongoDB(BaseQueryRunner):
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connectionString": {"type": "string", "title": "Connection String"},
|
||||
"username": {"type": "string"},
|
||||
"password": {"type": "string"},
|
||||
"dbName": {"type": "string", "title": "Database Name"},
|
||||
"replicaSetName": {"type": "string", "title": "Replica Set Name"},
|
||||
"readPreference": {
|
||||
@@ -147,6 +149,7 @@ class MongoDB(BaseQueryRunner):
|
||||
"title": "Replica Set Read Preference",
|
||||
},
|
||||
},
|
||||
"secret": ["password"],
|
||||
"required": ["connectionString", "dbName"],
|
||||
}
|
||||
|
||||
@@ -176,6 +179,12 @@ class MongoDB(BaseQueryRunner):
|
||||
if readPreference:
|
||||
kwargs["readPreference"] = readPreference
|
||||
|
||||
if "username" in self.configuration:
|
||||
kwargs["username"] = self.configuration["username"]
|
||||
|
||||
if "password" in self.configuration:
|
||||
kwargs["password"] = self.configuration["password"]
|
||||
|
||||
db_connection = pymongo.MongoClient(
|
||||
self.configuration["connectionString"], **kwargs
|
||||
)
|
||||
|
||||
@@ -169,7 +169,7 @@ class PostgreSQL(BaseSQLQueryRunner):
|
||||
},
|
||||
"order": ["host", "port", "user", "password"],
|
||||
"required": ["dbname"],
|
||||
"secret": ["password"],
|
||||
"secret": ["password", "sslrootcertFile", "sslcertFile", "sslkeyFile"],
|
||||
"extra_options": [
|
||||
"sslmode",
|
||||
"sslrootcertFile",
|
||||
|
||||
@@ -53,6 +53,7 @@ class TreasureData(BaseQueryRunner):
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"secret": ["apikey"],
|
||||
"required": ["apikey", "db"],
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ class YandexMetrica(BaseSQLQueryRunner):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {"token": {"type": "string", "title": "OAuth Token"}},
|
||||
"secret": ["token"],
|
||||
"required": ["token"],
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def public_widget(widget):
|
||||
def public_dashboard(dashboard):
|
||||
dashboard_dict = project(
|
||||
serialize_dashboard(dashboard, with_favorite_state=False),
|
||||
("name", "layout", "dashboard_filters_enabled", "updated_at", "created_at"),
|
||||
("name", "layout", "dashboard_filters_enabled", "updated_at", "created_at", "options"),
|
||||
)
|
||||
|
||||
widget_list = (
|
||||
@@ -257,6 +257,7 @@ def serialize_dashboard(obj, with_widgets=False, user=None, with_favorite_state=
|
||||
"layout": layout,
|
||||
"dashboard_filters_enabled": obj.dashboard_filters_enabled,
|
||||
"widgets": widgets,
|
||||
"options": obj.options,
|
||||
"is_archived": obj.is_archived,
|
||||
"is_draft": obj.is_draft,
|
||||
"tags": obj.tags or [],
|
||||
|
||||
@@ -11,6 +11,7 @@ from .helpers import (
|
||||
int_or_none,
|
||||
set_from_string,
|
||||
add_decode_responses_to_redis_url,
|
||||
cast_int_or_default
|
||||
)
|
||||
from .organization import DATE_FORMAT, TIME_FORMAT # noqa
|
||||
|
||||
@@ -304,7 +305,7 @@ RATELIMIT_ENABLED = parse_boolean(os.environ.get("REDASH_RATELIMIT_ENABLED", "tr
|
||||
THROTTLE_LOGIN_PATTERN = os.environ.get("REDASH_THROTTLE_LOGIN_PATTERN", "50/hour")
|
||||
LIMITER_STORAGE = os.environ.get("REDASH_LIMITER_STORAGE", REDIS_URL)
|
||||
|
||||
# CORS settings for the Query Result API (and possbily future external APIs).
|
||||
# CORS settings for the Query Result API (and possibly future external APIs).
|
||||
# In most cases all you need to do is set REDASH_CORS_ACCESS_CONTROL_ALLOW_ORIGIN
|
||||
# to the calling domain (or domains in a comma separated list).
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN = set_from_string(
|
||||
@@ -511,4 +512,6 @@ ENFORCE_CSRF = parse_boolean(
|
||||
os.environ.get("REDASH_ENFORCE_CSRF", "false")
|
||||
)
|
||||
|
||||
# Databricks
|
||||
|
||||
CSRF_TIME_LIMIT = int(os.environ.get("REDASH_CSRF_TIME_LIMIT", 3600 * 6))
|
||||
|
||||
@@ -61,3 +61,14 @@ def database_key_definitions(default):
|
||||
# Since you can define custom primary key types using `database_key_definitions`, you may want to load certain extensions when creating the database.
|
||||
# To do so, simply add the name of the extension you'd like to load to this list.
|
||||
database_extensions = []
|
||||
|
||||
|
||||
# If you'd like to limit the amount of concurrent query executions made by a certain org or user,
|
||||
# implement this method by returning a boolean which would indicate if the limit has reached.
|
||||
# If you return `True`, the query execution would move to a waiting list and would only be executed
|
||||
# when a spot clears up for it within the defined capacity.
|
||||
# `entity` is either "user" or "org".
|
||||
# `executions` is the number of currently running query execution jobs for the specific user/org.
|
||||
# `meta` is the query execution job's meta attribute.
|
||||
def capacity_reached_for(entity, executions, meta):
|
||||
return False
|
||||
@@ -29,6 +29,11 @@ def parse_boolean(s):
|
||||
else:
|
||||
raise ValueError("Invalid boolean value %r" % s)
|
||||
|
||||
def cast_int_or_default(val, default=None):
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def int_or_none(value):
|
||||
if value is None:
|
||||
|
||||
@@ -3,7 +3,6 @@ from .general import (
|
||||
version_check,
|
||||
send_mail,
|
||||
sync_user_details,
|
||||
purge_failed_jobs,
|
||||
)
|
||||
from .queries import (
|
||||
enqueue_query,
|
||||
@@ -17,6 +16,7 @@ from .queries import (
|
||||
from .alerts import check_alerts_for_query
|
||||
from .failure_report import send_aggregated_errors
|
||||
from .worker import Worker, Queue, Job
|
||||
from .capacity import cleanup_waiting_lists
|
||||
from .schedule import rq_scheduler, schedule_periodic_jobs, periodic_job_definitions
|
||||
|
||||
from redash import rq_redis_connection
|
||||
|
||||
135
redash/tasks/capacity.py
Normal file
135
redash/tasks/capacity.py
Normal file
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
@@ -2,13 +2,10 @@ import requests
|
||||
from datetime import datetime
|
||||
|
||||
from flask_mail import Message
|
||||
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 import mail, models, settings
|
||||
from redash.models import users
|
||||
from redash.version_check import run_version_check
|
||||
from redash.worker import job, get_job_logger, default_operational_queues
|
||||
from redash.worker import job, get_job_logger
|
||||
from redash.tasks.worker import Queue
|
||||
from redash.query_runner import NotSupported
|
||||
|
||||
@@ -94,35 +91,3 @@ def get_schema(data_source_id, refresh):
|
||||
|
||||
def sync_user_details():
|
||||
users.sync_last_active_at()
|
||||
|
||||
|
||||
def purge_failed_jobs():
|
||||
with Connection(rq_redis_connection):
|
||||
queues = [q for q in Queue.all() if q.name not in default_operational_queues]
|
||||
for queue in queues:
|
||||
failed_job_ids = FailedJobRegistry(queue=queue).get_job_ids()
|
||||
failed_jobs = Job.fetch_many(failed_job_ids, rq_redis_connection)
|
||||
stale_jobs = []
|
||||
for failed_job in failed_jobs:
|
||||
# the job may not actually exist anymore in Redis
|
||||
if not failed_job:
|
||||
continue
|
||||
# the job could have an empty ended_at value in case
|
||||
# of a worker dying before it can save the ended_at value,
|
||||
# in which case we also consider them stale
|
||||
if not failed_job.ended_at:
|
||||
stale_jobs.append(failed_job)
|
||||
elif (
|
||||
datetime.utcnow() - failed_job.ended_at
|
||||
).total_seconds() > settings.JOB_DEFAULT_FAILURE_TTL:
|
||||
stale_jobs.append(failed_job)
|
||||
|
||||
for stale_job in stale_jobs:
|
||||
stale_job.delete()
|
||||
|
||||
if stale_jobs:
|
||||
logger.info(
|
||||
"Purged %d old failed jobs from the %s queue.",
|
||||
len(stale_jobs),
|
||||
queue.name,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import signal
|
||||
import time
|
||||
import redis
|
||||
from uuid import uuid4
|
||||
|
||||
from rq import get_current_job
|
||||
from rq.job import JobStatus
|
||||
@@ -86,11 +87,14 @@ def enqueue_query(
|
||||
|
||||
queue = Queue(queue_name)
|
||||
enqueue_kwargs = {
|
||||
"job_id": f"org:{data_source.org_id}:user:{user_id}:id:{uuid4()}",
|
||||
"user_id": user_id,
|
||||
"scheduled_query_id": scheduled_query_id,
|
||||
"is_api_key": is_api_key,
|
||||
"job_timeout": time_limit,
|
||||
"failure_ttl": settings.JOB_DEFAULT_FAILURE_TTL,
|
||||
"meta": {
|
||||
"is_query_execution": True,
|
||||
"data_source_id": data_source.id,
|
||||
"org_id": data_source.org_id,
|
||||
"scheduled": scheduled_query_id is not None,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user