mirror of
https://github.com/getredash/redash.git
synced 2025-12-20 01:47:39 -05:00
Compare commits
52 Commits
saml-setti
...
query-base
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19343a0520 | ||
|
|
c1ed8848f0 | ||
|
|
b40070d7f5 | ||
|
|
fa2b57a209 | ||
|
|
132fed64b3 | ||
|
|
bd9ce68f68 | ||
|
|
0c0b62ae1a | ||
|
|
08bcdf77d0 | ||
|
|
aa2064b1ab | ||
|
|
d0a787cab1 | ||
|
|
a741341938 | ||
|
|
53385fa24b | ||
|
|
fa7ecca485 | ||
|
|
8f484706b1 | ||
|
|
e2e8714155 | ||
|
|
c6bf8a1c55 | ||
|
|
12f71925c2 | ||
|
|
cae088f35b | ||
|
|
a3c79f26b9 | ||
|
|
c7c92a3192 | ||
|
|
55cf17aa47 | ||
|
|
8dd76a00c5 | ||
|
|
e242ac2b10 | ||
|
|
66463aedd4 | ||
|
|
8a6524c1ba | ||
|
|
9097feb100 | ||
|
|
db4e97fa6f | ||
|
|
0d4615a482 | ||
|
|
ff008a076b | ||
|
|
8d548ecbac | ||
|
|
2992c382d1 | ||
|
|
f396c96457 | ||
|
|
8bfcbf21e3 | ||
|
|
8a1640c4e7 | ||
|
|
a37e7f93dc | ||
|
|
cc34e781d3 | ||
|
|
6aa0ea715e | ||
|
|
6c27619671 | ||
|
|
6eeb3b3eb2 | ||
|
|
d40edb81c2 | ||
|
|
f128b4b85f | ||
|
|
264fb5798d | ||
|
|
90023ac435 | ||
|
|
df755fbc17 | ||
|
|
e555642844 | ||
|
|
bdd7b146ae | ||
|
|
b7478defec | ||
|
|
bb0d7830c9 | ||
|
|
137aa22dd4 | ||
|
|
9cf396599a | ||
|
|
b70f0fa921 | ||
|
|
5e3613d6cb |
@@ -1,4 +1,4 @@
|
|||||||
FROM cypress/browsers:chrome67
|
FROM cypress/browsers:node14.0.0-chrome84
|
||||||
|
|
||||||
ENV APP /usr/src/app
|
ENV APP /usr/src/app
|
||||||
WORKDIR $APP
|
WORKDIR $APP
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -6,9 +6,12 @@ ARG skip_frontend_build
|
|||||||
ENV CYPRESS_INSTALL_BINARY=0
|
ENV CYPRESS_INSTALL_BINARY=0
|
||||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
|
||||||
|
|
||||||
|
RUN useradd -m -d /frontend redash
|
||||||
|
USER redash
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
COPY package.json package-lock.json /frontend/
|
COPY --chown=redash package.json package-lock.json /frontend/
|
||||||
COPY viz-lib /frontend/viz-lib
|
COPY --chown=redash viz-lib /frontend/viz-lib
|
||||||
|
|
||||||
# Controls whether to instrument code for coverage information
|
# Controls whether to instrument code for coverage information
|
||||||
ARG code_coverage
|
ARG code_coverage
|
||||||
@@ -16,8 +19,8 @@ ENV BABEL_ENV=${code_coverage:+test}
|
|||||||
|
|
||||||
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi
|
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi
|
||||||
|
|
||||||
COPY client /frontend/client
|
COPY --chown=redash client /frontend/client
|
||||||
COPY webpack.config.js /frontend/
|
COPY --chown=redash webpack.config.js /frontend/
|
||||||
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
|
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
|
||||||
FROM python:3.7-slim
|
FROM python:3.7-slim
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ worker() {
|
|||||||
export WORKERS_COUNT=${WORKERS_COUNT:-2}
|
export WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||||
export QUEUES=${QUEUES:-}
|
export QUEUES=${QUEUES:-}
|
||||||
|
|
||||||
supervisord -c worker.conf
|
exec supervisord -c worker.conf
|
||||||
}
|
}
|
||||||
|
|
||||||
dev_worker() {
|
dev_worker() {
|
||||||
|
|||||||
@@ -20,6 +20,21 @@ module.exports = {
|
|||||||
// allow debugger during development
|
// allow debugger during development
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
||||||
"jsx-a11y/anchor-is-valid": "off",
|
"jsx-a11y/anchor-is-valid": "off",
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
paths: [
|
||||||
|
{
|
||||||
|
name: "antd",
|
||||||
|
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "antd/lib",
|
||||||
|
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -141,6 +141,7 @@ a.label-tag {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.query-fullscreen {
|
.query-fullscreen {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default function ApplicationLayout({ children }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
|
<DynamicComponent name="ApplicationWrapper">
|
||||||
<div className="application-layout-side-menu">
|
<div className="application-layout-side-menu">
|
||||||
<DynamicComponent name="ApplicationDesktopNavbar">
|
<DynamicComponent name="ApplicationDesktopNavbar">
|
||||||
<DesktopNavbar />
|
<DesktopNavbar />
|
||||||
@@ -26,6 +27,7 @@ export default function ApplicationLayout({ children }) {
|
|||||||
</nav>
|
</nav>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
</DynamicComponent>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { isObject, get } from "lodash";
|
import { get, isObject } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import "./ErrorMessage.less";
|
import "./ErrorMessage.less";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
import { ErrorMessageDetails } from "@/components/ApplicationArea/ErrorMessageDetails";
|
||||||
|
|
||||||
function getErrorMessageByStatus(status, defaultMessage) {
|
function getErrorMessageByStatus(status, defaultMessage) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -31,21 +33,30 @@ function getErrorMessage(error) {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ErrorMessage({ error }) {
|
export default function ErrorMessage({ error, message }) {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
|
const errorDetailsProps = {
|
||||||
|
error,
|
||||||
|
message: message || getErrorMessage(error),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="error-message-container" data-test="ErrorMessage">
|
<div className="error-message-container" data-test="ErrorMessage" role="alert">
|
||||||
<div className="error-state bg-white tiled">
|
<div className="error-state bg-white tiled">
|
||||||
<div className="error-state__icon">
|
<div className="error-state__icon">
|
||||||
<i className="zmdi zmdi-alert-circle-o" />
|
<i className="zmdi zmdi-alert-circle-o" />
|
||||||
</div>
|
</div>
|
||||||
<div className="error-state__details">
|
<div className="error-state__details">
|
||||||
<h4>{getErrorMessage(error)}</h4>
|
<DynamicComponent
|
||||||
|
name="ErrorMessageDetails"
|
||||||
|
fallback={<ErrorMessageDetails {...errorDetailsProps} />}
|
||||||
|
{...errorDetailsProps}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,4 +65,5 @@ export default function ErrorMessage({ error }) {
|
|||||||
|
|
||||||
ErrorMessage.propTypes = {
|
ErrorMessage.propTypes = {
|
||||||
error: PropTypes.object.isRequired,
|
error: PropTypes.object.isRequired,
|
||||||
|
message: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
export function ErrorMessageDetails(props) {
|
||||||
|
return <h4>{props.message}</h4>;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorMessageDetails.propTypes = {
|
||||||
|
error: PropTypes.instanceOf(Error).isRequired,
|
||||||
|
message: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
@@ -9,7 +9,7 @@ export default function handleNavigationIntent(event) {
|
|||||||
}
|
}
|
||||||
element = element.parentNode;
|
element = element.parentNode;
|
||||||
}
|
}
|
||||||
if (!element || !element.hasAttribute("href") || element.hasAttribute("download")) {
|
if (!element || !element.hasAttribute("href") || element.hasAttribute("download") || element.dataset.skipRouter) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
4
client/app/components/DialogWrapper.d.ts
vendored
4
client/app/components/DialogWrapper.d.ts
vendored
@@ -22,8 +22,8 @@ export function wrap<ROk = void, P = {}, RCancel = void>(
|
|||||||
props?: P
|
props?: P
|
||||||
) => {
|
) => {
|
||||||
update: (props: P) => void;
|
update: (props: P) => void;
|
||||||
onClose: (handler: (result: ROk) => Promise<void>) => void;
|
onClose: (handler: (result: ROk) => Promise<void> | void) => void;
|
||||||
onDismiss: (handler: (result: RCancel) => Promise<void>) => void;
|
onDismiss: (handler: (result: RCancel) => Promise<void> | void) => void;
|
||||||
close: (result: ROk) => void;
|
close: (result: ROk) => void;
|
||||||
dismiss: (result: RCancel) => void;
|
dismiss: (result: RCancel) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { includes, words, capitalize, clone, isNull } from "lodash";
|
import { includes, words, capitalize, clone, isNull, map, get, find } from "lodash";
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Checkbox from "antd/lib/checkbox";
|
import Checkbox from "antd/lib/checkbox";
|
||||||
import Modal from "antd/lib/modal";
|
import Modal from "antd/lib/modal";
|
||||||
@@ -11,6 +11,8 @@ import Divider from "antd/lib/divider";
|
|||||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||||
import QuerySelector from "@/components/QuerySelector";
|
import QuerySelector from "@/components/QuerySelector";
|
||||||
import { Query } from "@/services/query";
|
import { Query } from "@/services/query";
|
||||||
|
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||||
|
import QueryBasedParameterMappingTable from "./query-based-parameter/QueryBasedParameterMappingTable";
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||||
@@ -69,17 +71,27 @@ NameInput.propTypes = {
|
|||||||
function EditParameterSettingsDialog(props) {
|
function EditParameterSettingsDialog(props) {
|
||||||
const [param, setParam] = useState(clone(props.parameter));
|
const [param, setParam] = useState(clone(props.parameter));
|
||||||
const [isNameValid, setIsNameValid] = useState(true);
|
const [isNameValid, setIsNameValid] = useState(true);
|
||||||
const [initialQuery, setInitialQuery] = useState();
|
const [paramQuery, setParamQuery] = useState();
|
||||||
|
const mappingParameters = useMemo(
|
||||||
|
() =>
|
||||||
|
map(paramQuery && paramQuery.getParametersDefs(), mappingParam => ({
|
||||||
|
mappingParam,
|
||||||
|
existingMapping: get(param.parameterMapping, mappingParam.name, {
|
||||||
|
mappingType: QueryBasedParameterMappingType.UNDEFINED,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
[param.parameterMapping, paramQuery]
|
||||||
|
);
|
||||||
|
|
||||||
const isNew = !props.parameter.name;
|
const isNew = !props.parameter.name;
|
||||||
|
|
||||||
// fetch query by id
|
// fetch query by id
|
||||||
|
const initialQueryId = useRef(props.parameter.queryId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const queryId = props.parameter.queryId;
|
if (initialQueryId.current) {
|
||||||
if (queryId) {
|
Query.get({ id: initialQueryId.current }).then(setParamQuery);
|
||||||
Query.get({ id: queryId }).then(setInitialQuery);
|
|
||||||
}
|
}
|
||||||
}, [props.parameter.queryId]);
|
}, []);
|
||||||
|
|
||||||
function isFulfilled() {
|
function isFulfilled() {
|
||||||
// name
|
// name
|
||||||
@@ -93,10 +105,16 @@ function EditParameterSettingsDialog(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// query
|
// query
|
||||||
if (param.type === "query" && !param.queryId) {
|
if (param.type === "query") {
|
||||||
|
if (!param.queryId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (find(mappingParameters, { existingMapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED } })) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +158,7 @@ function EditParameterSettingsDialog(props) {
|
|||||||
type={param.type}
|
type={param.type}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Form.Item label="Title" {...formItemProps}>
|
<Form.Item required label="Title" {...formItemProps}>
|
||||||
<Input
|
<Input
|
||||||
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
||||||
onChange={e => setParam({ ...param, title: e.target.value })}
|
onChange={e => setParam({ ...param, title: e.target.value })}
|
||||||
@@ -187,14 +205,28 @@ function EditParameterSettingsDialog(props) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
{param.type === "query" && (
|
{param.type === "query" && (
|
||||||
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
|
<Form.Item label="Query" help="Select query to load dropdown values from" required {...formItemProps}>
|
||||||
<QuerySelector
|
<QuerySelector
|
||||||
selectedQuery={initialQuery}
|
selectedQuery={paramQuery}
|
||||||
onChange={q => setParam({ ...param, queryId: q && q.id })}
|
onChange={q => {
|
||||||
|
if (q) {
|
||||||
|
setParamQuery(q);
|
||||||
|
setParam({ ...param, queryId: q.id, parameterMapping: {} });
|
||||||
|
}
|
||||||
|
}}
|
||||||
type="select"
|
type="select"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
{param.type === "query" && paramQuery && paramQuery.hasParameters() && (
|
||||||
|
<Form.Item className="m-t-15 m-b-5" label="Parameters" required {...formItemProps}>
|
||||||
|
<QueryBasedParameterMappingTable
|
||||||
|
param={param}
|
||||||
|
mappingParameters={mappingParameters}
|
||||||
|
onChangeParam={setParam}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
{(param.type === "enum" || param.type === "query") && (
|
{(param.type === "enum" || param.type === "query") && (
|
||||||
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useReducer } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { values } from "lodash";
|
||||||
|
import Button from "antd/lib/button";
|
||||||
|
import Tooltip from "antd/lib/tooltip";
|
||||||
|
import Radio from "antd/lib/radio";
|
||||||
|
import Typography from "antd/lib/typography/Typography";
|
||||||
|
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||||
|
import InputPopover from "@/components/InputPopover";
|
||||||
|
import Form from "antd/lib/form";
|
||||||
|
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||||
|
|
||||||
|
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
||||||
|
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||||
|
export default function QueryBasedParameterMappingEditor({ parameter, mapping, searchAvailable, onChange }) {
|
||||||
|
const [showPopover, setShowPopover] = useState(false);
|
||||||
|
const [newMapping, setNewMapping] = useReducer((prevState, updates) => ({ ...prevState, ...updates }), mapping);
|
||||||
|
|
||||||
|
const newMappingRef = useRef(newMapping);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
mapping.mappingType !== newMappingRef.current.mappingType ||
|
||||||
|
mapping.staticValue !== newMappingRef.current.staticValue
|
||||||
|
) {
|
||||||
|
setNewMapping(mapping);
|
||||||
|
}
|
||||||
|
}, [mapping]);
|
||||||
|
|
||||||
|
const parameterRef = useRef(parameter);
|
||||||
|
useEffect(() => {
|
||||||
|
parameterRef.current.setValue(mapping.staticValue);
|
||||||
|
}, [mapping.staticValue]);
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
setNewMapping(mapping);
|
||||||
|
setShowPopover(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOk = () => {
|
||||||
|
onChange(newMapping);
|
||||||
|
setShowPopover(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentState = <Text type="secondary">Pick a type</Text>;
|
||||||
|
if (mapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH) {
|
||||||
|
currentState = "Dropdown Search";
|
||||||
|
} else if (mapping.mappingType === QueryBasedParameterMappingType.STATIC) {
|
||||||
|
currentState = `Value: ${mapping.staticValue}`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{currentState}
|
||||||
|
<InputPopover
|
||||||
|
placement="left"
|
||||||
|
trigger="click"
|
||||||
|
header="Edit Parameter Source"
|
||||||
|
okButtonProps={{
|
||||||
|
disabled: newMapping.mappingType === QueryBasedParameterMappingType.STATIC && parameter.isEmpty,
|
||||||
|
}}
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={onCancel}
|
||||||
|
content={
|
||||||
|
<Form>
|
||||||
|
<Form.Item className="m-b-15" label="Source" {...formItemProps}>
|
||||||
|
<Radio.Group
|
||||||
|
value={newMapping.mappingType}
|
||||||
|
onChange={({ target }) => setNewMapping({ mappingType: target.value })}>
|
||||||
|
<Radio
|
||||||
|
className="radio"
|
||||||
|
value={QueryBasedParameterMappingType.DROPDOWN_SEARCH}
|
||||||
|
disabled={!searchAvailable || parameter.type !== "text"}>
|
||||||
|
Dropdown Search{" "}
|
||||||
|
{(!searchAvailable || parameter.type !== "text") && (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
parameter.type !== "text"
|
||||||
|
? "Dropdown Search is only available for Text Parameters"
|
||||||
|
: "There is already a parameter mapped with the Dropdown Search type."
|
||||||
|
}>
|
||||||
|
<QuestionCircleFilledIcon />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Radio>
|
||||||
|
<Radio className="radio" value={QueryBasedParameterMappingType.STATIC}>
|
||||||
|
Static Value
|
||||||
|
</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
{newMapping.mappingType === QueryBasedParameterMappingType.STATIC && (
|
||||||
|
<Form.Item label="Value" required {...formItemProps}>
|
||||||
|
<ParameterValueInput
|
||||||
|
type={parameter.type}
|
||||||
|
value={parameter.normalizedValue}
|
||||||
|
enumOptions={parameter.enumOptions}
|
||||||
|
queryId={parameter.queryId}
|
||||||
|
parameter={parameter}
|
||||||
|
onSelect={value => {
|
||||||
|
parameter.setValue(value);
|
||||||
|
setNewMapping({ staticValue: parameter.getExecutionValue({ joinListValues: true }) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
}
|
||||||
|
visible={showPopover}
|
||||||
|
onVisibleChange={setShowPopover}>
|
||||||
|
<Button className="m-l-5" size="small" type="dashed">
|
||||||
|
<EditOutlinedIcon />
|
||||||
|
</Button>
|
||||||
|
</InputPopover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBasedParameterMappingEditor.propTypes = {
|
||||||
|
parameter: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
|
mapping: PropTypes.shape({
|
||||||
|
mappingType: PropTypes.oneOf(values(QueryBasedParameterMappingType)),
|
||||||
|
staticValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
|
}),
|
||||||
|
searchAvailable: PropTypes.bool,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
QueryBasedParameterMappingEditor.defaultProps = {
|
||||||
|
mapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED, staticValue: undefined },
|
||||||
|
searchAvailable: false,
|
||||||
|
onChange: () => {},
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { findKey } from "lodash";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import Table from "antd/lib/table";
|
||||||
|
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||||
|
import QueryBasedParameterMappingEditor from "./QueryBasedParameterMappingEditor";
|
||||||
|
|
||||||
|
export default function QueryBasedParameterMappingTable({ param, mappingParameters, onChangeParam }) {
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
dataSource={mappingParameters}
|
||||||
|
size="middle"
|
||||||
|
pagination={false}
|
||||||
|
rowKey={({ mappingParam }) => `param${mappingParam.name}`}>
|
||||||
|
<Table.Column title="Title" key="title" render={({ mappingParam }) => mappingParam.getTitle()} />
|
||||||
|
<Table.Column
|
||||||
|
title="Keyword"
|
||||||
|
key="keyword"
|
||||||
|
className="keyword"
|
||||||
|
render={({ mappingParam }) => <code>{`{{ ${mappingParam.name} }}`}</code>}
|
||||||
|
/>
|
||||||
|
<Table.Column
|
||||||
|
title="Value Source"
|
||||||
|
key="source"
|
||||||
|
render={({ mappingParam, existingMapping }) => (
|
||||||
|
<QueryBasedParameterMappingEditor
|
||||||
|
parameter={mappingParam.setValue(existingMapping.staticValue)}
|
||||||
|
mapping={existingMapping}
|
||||||
|
searchAvailable={
|
||||||
|
!findKey(param.parameterMapping, {
|
||||||
|
mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH,
|
||||||
|
}) || existingMapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH
|
||||||
|
}
|
||||||
|
onChange={mapping =>
|
||||||
|
onChangeParam({
|
||||||
|
...param,
|
||||||
|
parameterMapping: { ...param.parameterMapping, [mappingParam.name]: mapping },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBasedParameterMappingTable.propTypes = {
|
||||||
|
param: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
|
mappingParameters: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
|
||||||
|
onChangeParam: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
QueryBasedParameterMappingTable.defaultProps = {
|
||||||
|
mappingParameters: [],
|
||||||
|
onChangeParam: () => {},
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { startsWith, get } from "lodash";
|
import { startsWith, get, some, mapValues } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
@@ -7,7 +7,7 @@ import Drawer from "antd/lib/drawer";
|
|||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||||
import BigMessage from "@/components/BigMessage";
|
import BigMessage from "@/components/BigMessage";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent, { registerComponent } from "@/components/DynamicComponent";
|
||||||
|
|
||||||
import "./HelpTrigger.less";
|
import "./HelpTrigger.less";
|
||||||
|
|
||||||
@@ -16,7 +16,8 @@ const HELP_PATH = "/help";
|
|||||||
const IFRAME_TIMEOUT = 20000;
|
const IFRAME_TIMEOUT = 20000;
|
||||||
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
|
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
|
||||||
|
|
||||||
export const TYPES = {
|
export const TYPES = mapValues(
|
||||||
|
{
|
||||||
HOME: ["", "Help"],
|
HOME: ["", "Help"],
|
||||||
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
|
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
|
||||||
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
|
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
|
||||||
@@ -26,7 +27,10 @@ export const TYPES = {
|
|||||||
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
|
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
|
||||||
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
|
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
|
||||||
DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"],
|
DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"],
|
||||||
DS_GOOGLE_SPREADSHEETS: ["/data-sources/querying-a-google-spreadsheet", "Guide: Help Setting up Google Spreadsheets"],
|
DS_GOOGLE_SPREADSHEETS: [
|
||||||
|
"/data-sources/querying-a-google-spreadsheet",
|
||||||
|
"Guide: Help Setting up Google Spreadsheets",
|
||||||
|
],
|
||||||
DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"],
|
DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"],
|
||||||
DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"],
|
DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"],
|
||||||
DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
|
DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
|
||||||
@@ -39,27 +43,43 @@ export const TYPES = {
|
|||||||
"Guide: Managing Query Permissions",
|
"Guide: Managing Query Permissions",
|
||||||
],
|
],
|
||||||
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
|
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
|
||||||
};
|
GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"],
|
||||||
|
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"],
|
||||||
|
QUERIES: ["/help/user-guide/querying", "Guide: Queries"],
|
||||||
|
ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
|
||||||
|
},
|
||||||
|
([url, title]) => [DOMAIN + HELP_PATH + url, title]
|
||||||
|
);
|
||||||
|
|
||||||
export default class HelpTrigger extends React.Component {
|
const HelpTriggerPropTypes = {
|
||||||
static propTypes = {
|
type: PropTypes.string,
|
||||||
type: PropTypes.oneOf(Object.keys(TYPES)),
|
|
||||||
href: PropTypes.string,
|
href: PropTypes.string,
|
||||||
title: PropTypes.node,
|
title: PropTypes.node,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
showTooltip: PropTypes.bool,
|
showTooltip: PropTypes.bool,
|
||||||
|
renderAsLink: PropTypes.bool,
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
const HelpTriggerDefaultProps = {
|
||||||
type: null,
|
type: null,
|
||||||
href: null,
|
href: null,
|
||||||
title: null,
|
title: null,
|
||||||
className: null,
|
className: null,
|
||||||
showTooltip: true,
|
showTooltip: true,
|
||||||
|
renderAsLink: false,
|
||||||
children: <i className="fa fa-question-circle" />,
|
children: <i className="fa fa-question-circle" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName = null) {
|
||||||
|
return class HelpTrigger extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
...HelpTriggerPropTypes,
|
||||||
|
type: PropTypes.oneOf(Object.keys(types)),
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = HelpTriggerDefaultProps;
|
||||||
|
|
||||||
iframeRef = React.createRef();
|
iframeRef = React.createRef();
|
||||||
|
|
||||||
iframeLoadingTimeout = null;
|
iframeLoadingTimeout = null;
|
||||||
@@ -96,7 +116,7 @@ export default class HelpTrigger extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onPostMessageReceived = event => {
|
onPostMessageReceived = event => {
|
||||||
if (!startsWith(event.origin, DOMAIN)) {
|
if (!some(allowedDomains, domain => startsWith(event.origin, domain))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,14 +129,18 @@ export default class HelpTrigger extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
getUrl = () => {
|
getUrl = () => {
|
||||||
const helpTriggerType = get(TYPES, this.props.type);
|
const helpTriggerType = get(types, this.props.type);
|
||||||
return helpTriggerType ? DOMAIN + HELP_PATH + helpTriggerType[0] : this.props.href;
|
return helpTriggerType ? helpTriggerType[0] : this.props.href;
|
||||||
};
|
};
|
||||||
|
|
||||||
openDrawer = () => {
|
openDrawer = e => {
|
||||||
|
// keep "open in new tab" behavior
|
||||||
|
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
this.setState({ visible: true });
|
this.setState({ visible: true });
|
||||||
// wait for drawer animation to complete so there's no animation jank
|
// wait for drawer animation to complete so there's no animation jank
|
||||||
setTimeout(() => this.loadIframe(this.getUrl()), 300);
|
setTimeout(() => this.loadIframe(this.getUrl()), 300);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
closeDrawer = event => {
|
closeDrawer = event => {
|
||||||
@@ -128,11 +152,16 @@ export default class HelpTrigger extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const tooltip = get(TYPES, `${this.props.type}[1]`, this.props.title);
|
const targetUrl = this.getUrl();
|
||||||
|
if (!targetUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltip = get(types, `${this.props.type}[1]`, this.props.title);
|
||||||
const className = cx("help-trigger", this.props.className);
|
const className = cx("help-trigger", this.props.className);
|
||||||
const url = this.state.currentUrl;
|
const url = this.state.currentUrl;
|
||||||
|
const isAllowedDomain = some(allowedDomains, domain => startsWith(url || targetUrl, domain));
|
||||||
const isAllowedDomain = startsWith(url || this.getUrl(), DOMAIN);
|
const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
@@ -141,26 +170,25 @@ export default class HelpTrigger extends React.Component {
|
|||||||
this.props.showTooltip ? (
|
this.props.showTooltip ? (
|
||||||
<>
|
<>
|
||||||
{tooltip}
|
{tooltip}
|
||||||
{!isAllowedDomain && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />}
|
{shouldRenderAsLink && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />}
|
||||||
</>
|
</>
|
||||||
) : null
|
) : null
|
||||||
}>
|
}>
|
||||||
{isAllowedDomain ? (
|
<Link
|
||||||
<a onClick={this.openDrawer} className={className}>
|
href={url || this.getUrl()}
|
||||||
{this.props.children}
|
className={className}
|
||||||
</a>
|
rel="noopener noreferrer"
|
||||||
) : (
|
target="_blank"
|
||||||
<Link href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank">
|
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}>
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Drawer
|
<Drawer
|
||||||
placement="right"
|
placement="right"
|
||||||
closable={false}
|
closable={false}
|
||||||
onClose={this.closeDrawer}
|
onClose={this.closeDrawer}
|
||||||
visible={this.state.visible}
|
visible={this.state.visible}
|
||||||
className="help-drawer"
|
className={cx("help-drawer", drawerClassName)}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
width={400}>
|
width={400}>
|
||||||
<div className="drawer-wrapper">
|
<div className="drawer-wrapper">
|
||||||
@@ -184,7 +212,7 @@ export default class HelpTrigger extends React.Component {
|
|||||||
{!this.state.error && (
|
{!this.state.error && (
|
||||||
<iframe
|
<iframe
|
||||||
ref={this.iframeRef}
|
ref={this.iframeRef}
|
||||||
title="Redash Help"
|
title="Usage Help"
|
||||||
src="about:blank"
|
src="about:blank"
|
||||||
className={cx({ ready: !this.state.loading })}
|
className={cx({ ready: !this.state.loading })}
|
||||||
onLoad={this.onIframeLoaded}
|
onLoad={this.onIframeLoaded}
|
||||||
@@ -216,4 +244,14 @@ export default class HelpTrigger extends React.Component {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerComponent("HelpTrigger", helpTriggerWithTypes(TYPES, [DOMAIN]));
|
||||||
|
|
||||||
|
export default function HelpTrigger(props) {
|
||||||
|
return <DynamicComponent {...props} name="HelpTrigger" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
HelpTrigger.propTypes = HelpTriggerPropTypes;
|
||||||
|
HelpTrigger.defaultProps = HelpTriggerDefaultProps;
|
||||||
|
|||||||
57
client/app/components/InputPopover/index.jsx
Normal file
57
client/app/components/InputPopover/index.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import Button from "antd/lib/button";
|
||||||
|
import Popover from "antd/lib/popover";
|
||||||
|
|
||||||
|
import "./index.less";
|
||||||
|
|
||||||
|
export default function InputPopover({
|
||||||
|
header,
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
okButtonProps,
|
||||||
|
cancelButtonProps,
|
||||||
|
onCancel,
|
||||||
|
onOk,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
{...props}
|
||||||
|
content={
|
||||||
|
<div className="input-popover-content" data-test="InputPopoverContent">
|
||||||
|
{header && <header>{header}</header>}
|
||||||
|
{content}
|
||||||
|
<footer>
|
||||||
|
<Button onClick={onCancel} {...cancelButtonProps}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onOk} type="primary" {...okButtonProps}>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{children}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InputPopover.propTypes = {
|
||||||
|
header: PropTypes.node,
|
||||||
|
content: PropTypes.node,
|
||||||
|
children: PropTypes.node,
|
||||||
|
okButtonProps: PropTypes.object,
|
||||||
|
cancelButtonProps: PropTypes.object,
|
||||||
|
onOk: PropTypes.func,
|
||||||
|
onCancel: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
InputPopover.defaultProps = {
|
||||||
|
header: null,
|
||||||
|
children: null,
|
||||||
|
okButtonProps: null,
|
||||||
|
cancelButtonProps: null,
|
||||||
|
onOk: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
};
|
||||||
37
client/app/components/InputPopover/index.less
Normal file
37
client/app/components/InputPopover/index.less
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
@import "~antd/lib/modal/style/index"; // for ant @vars
|
||||||
|
|
||||||
|
.input-popover-content {
|
||||||
|
width: 390px;
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
display: block;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 0 16px 10px;
|
||||||
|
margin: 0 -16px 20px;
|
||||||
|
border-bottom: @border-width-base @border-style-base @border-color-split;
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 500;
|
||||||
|
color: @heading-color;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: @border-width-base @border-style-base @border-color-split;
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
margin: 0 -16px;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ function Link(props) {
|
|||||||
Link.Component = DefaultLinkComponent;
|
Link.Component = DefaultLinkComponent;
|
||||||
|
|
||||||
function DefaultButtonLinkComponent(props) {
|
function DefaultButtonLinkComponent(props) {
|
||||||
return <Button {...props} />;
|
return <Button role="button" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ButtonLink(props) {
|
function ButtonLink(props) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ParameterValueInput from "@/components/ParameterValueInput";
|
|||||||
import { ParameterMappingType } from "@/services/widget";
|
import { ParameterMappingType } from "@/services/widget";
|
||||||
import { Parameter, cloneParameter } from "@/services/parameters";
|
import { Parameter, cloneParameter } from "@/services/parameters";
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
|
import InputPopover from "@/components/InputPopover";
|
||||||
|
|
||||||
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
||||||
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
||||||
@@ -25,8 +26,6 @@ import CheckOutlinedIcon from "@ant-design/icons/CheckOutlined";
|
|||||||
|
|
||||||
import "./ParameterMappingInput.less";
|
import "./ParameterMappingInput.less";
|
||||||
|
|
||||||
const { Option } = Select;
|
|
||||||
|
|
||||||
export const MappingType = {
|
export const MappingType = {
|
||||||
DashboardAddNew: "dashboard-add-new",
|
DashboardAddNew: "dashboard-add-new",
|
||||||
DashboardMapToExisting: "dashboard-map-to-existing",
|
DashboardMapToExisting: "dashboard-map-to-existing",
|
||||||
@@ -208,19 +207,9 @@ export class ParameterMappingInput extends React.Component {
|
|||||||
|
|
||||||
renderDashboardMapToExisting() {
|
renderDashboardMapToExisting() {
|
||||||
const { mapping, existingParamNames } = this.props;
|
const { mapping, existingParamNames } = this.props;
|
||||||
|
const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName }));
|
||||||
|
|
||||||
return (
|
return <Select value={mapping.mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options} />;
|
||||||
<Select
|
|
||||||
value={mapping.mapTo}
|
|
||||||
onChange={mapTo => this.updateParamMapping({ mapTo })}
|
|
||||||
dropdownMatchSelectWidth={false}>
|
|
||||||
{map(existingParamNames, name => (
|
|
||||||
<Option value={name} key={name}>
|
|
||||||
{name}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStaticValue() {
|
renderStaticValue() {
|
||||||
@@ -325,43 +314,34 @@ class MappingEditor extends React.Component {
|
|||||||
this.setState({ visible: false });
|
this.setState({ visible: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
renderContent() {
|
render() {
|
||||||
const { mapping, inputError } = this.state;
|
const { visible, mapping, inputError } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
|
<InputPopover
|
||||||
<header>
|
placement="left"
|
||||||
|
trigger="click"
|
||||||
|
header={
|
||||||
|
<>
|
||||||
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
||||||
</header>
|
</>
|
||||||
|
}
|
||||||
|
content={
|
||||||
<ParameterMappingInput
|
<ParameterMappingInput
|
||||||
mapping={mapping}
|
mapping={mapping}
|
||||||
existingParamNames={this.props.existingParamNames}
|
existingParamNames={this.props.existingParamNames}
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
inputError={inputError}
|
inputError={inputError}
|
||||||
/>
|
/>
|
||||||
<footer>
|
|
||||||
<Button onClick={this.hide}>Cancel</Button>
|
|
||||||
<Button onClick={this.save} disabled={!!inputError} type="primary">
|
|
||||||
OK
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
onOk={this.save}
|
||||||
render() {
|
onCancel={this.hide}
|
||||||
const { visible, mapping } = this.state;
|
okButtonProps={{ disabled: !!inputError }}
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
placement="left"
|
|
||||||
trigger="click"
|
|
||||||
content={this.renderContent()}
|
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onVisibleChange={this.onVisibleChange}>
|
onVisibleChange={this.onVisibleChange}>
|
||||||
<Button size="small" type="dashed" data-test={`EditParamMappingButon-${mapping.param.name}`}>
|
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
|
||||||
<EditOutlinedIcon />
|
<EditOutlinedIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</Popover>
|
</InputPopover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import '~antd/lib/modal/style/index'; // for ant @vars
|
@import "~antd/lib/modal/style/index"; // for ant @vars
|
||||||
|
|
||||||
.parameters-mapping-list {
|
.parameters-mapping-list {
|
||||||
.keyword {
|
.keyword {
|
||||||
@@ -22,48 +22,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-mapping-editor {
|
|
||||||
width: 390px;
|
|
||||||
|
|
||||||
.radio {
|
|
||||||
display: block;
|
|
||||||
height: 30px;
|
|
||||||
line-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-item {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
padding: 0 16px 10px;
|
|
||||||
margin: 0 -16px 20px;
|
|
||||||
border-bottom: @border-width-base @border-style-base @border-color-split;
|
|
||||||
font-size: @font-size-lg;
|
|
||||||
font-weight: 500;
|
|
||||||
color: @heading-color;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
border-top: @border-width-base @border-style-base @border-color-split;
|
|
||||||
padding: 10px 16px 0;
|
|
||||||
margin: 0 -16px;
|
|
||||||
text-align: right;
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.parameter-mapping-title {
|
.parameter-mapping-title {
|
||||||
.text {
|
.text {
|
||||||
margin-right: 3px;
|
margin-right: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled, .fa {
|
&.disabled,
|
||||||
|
.fa {
|
||||||
color: #a4a4a4;
|
color: #a4a4a4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { isEqual, isEmpty } from "lodash";
|
import { isEqual, isEmpty, map } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Select from "antd/lib/select";
|
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
import InputNumber from "antd/lib/input-number";
|
import InputNumber from "antd/lib/input-number";
|
||||||
import DateParameter from "@/components/dynamic-parameters/DateParameter";
|
import DateParameter from "@/components/dynamic-parameters/DateParameter";
|
||||||
@@ -10,8 +10,6 @@ import QueryBasedParameterInput from "./QueryBasedParameterInput";
|
|||||||
|
|
||||||
import "./ParameterValueInput.less";
|
import "./ParameterValueInput.less";
|
||||||
|
|
||||||
const { Option } = Select;
|
|
||||||
|
|
||||||
const multipleValuesProps = {
|
const multipleValuesProps = {
|
||||||
maxTagCount: 3,
|
maxTagCount: 3,
|
||||||
maxTagTextLength: 10,
|
maxTagTextLength: 10,
|
||||||
@@ -98,25 +96,20 @@ class ParameterValueInput extends React.Component {
|
|||||||
const enumOptionsArray = enumOptions.split("\n").filter(v => v !== "");
|
const enumOptionsArray = enumOptions.split("\n").filter(v => v !== "");
|
||||||
// Antd Select doesn't handle null in multiple mode
|
// Antd Select doesn't handle null in multiple mode
|
||||||
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
|
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<SelectWithVirtualScroll
|
||||||
className={this.props.className}
|
className={this.props.className}
|
||||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||||
optionFilterProp="children"
|
optionFilterProp="children"
|
||||||
value={normalize(value)}
|
value={normalize(value)}
|
||||||
onChange={this.onSelect}
|
onChange={this.onSelect}
|
||||||
dropdownMatchSelectWidth={false}
|
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
|
||||||
showSearch
|
showSearch
|
||||||
showArrow
|
showArrow
|
||||||
style={{ minWidth: 60 }}
|
|
||||||
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
|
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
|
||||||
{...multipleValuesProps}>
|
{...multipleValuesProps}
|
||||||
{enumOptionsArray.map(option => (
|
/>
|
||||||
<Option key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
.@{ant-prefix}-input-number,
|
.@{ant-prefix}-input-number,
|
||||||
.@{ant-prefix}-select-selector,
|
.@{ant-prefix}-select-selector,
|
||||||
.@{ant-prefix}-picker {
|
.@{ant-prefix}-picker {
|
||||||
background-color: @input-dirty;
|
background-color: @input-dirty !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Parameter, createParameter } from "@/services/parameters";
|
|||||||
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
||||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||||
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
||||||
import { toHuman } from "@/lib/utils";
|
|
||||||
|
|
||||||
import "./Parameters.less";
|
import "./Parameters.less";
|
||||||
|
|
||||||
@@ -121,7 +120,7 @@ export default class Parameters extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
||||||
<div className="parameter-heading">
|
<div className="parameter-heading">
|
||||||
<label>{param.title || toHuman(param.name)}</label>
|
<label>{param.getTitle()}</label>
|
||||||
{editable && (
|
{editable && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-default btn-xs m-l-5"
|
className="btn btn-default btn-xs m-l-5"
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
|
import { find, isArray, get, first, map, intersection, isEqual, isEmpty, trim, debounce, isNil } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Select from "antd/lib/select";
|
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
||||||
|
|
||||||
const { Option } = Select;
|
const SEARCH_DEBOUNCE_TIME = 300;
|
||||||
|
|
||||||
|
function filterValuesThatAreNotInOptions(value, options) {
|
||||||
|
if (isArray(value)) {
|
||||||
|
const optionValues = map(options, option => option.value);
|
||||||
|
return intersection(value, optionValues);
|
||||||
|
}
|
||||||
|
const found = find(options, option => option.value === value) !== undefined;
|
||||||
|
return found ? value : get(first(options), "value");
|
||||||
|
}
|
||||||
|
|
||||||
export default class QueryBasedParameterInput extends React.Component {
|
export default class QueryBasedParameterInput extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
@@ -30,6 +39,7 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
options: [],
|
options: [],
|
||||||
value: null,
|
value: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
currentSearchTerm: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +48,10 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (this.props.queryId !== prevProps.queryId) {
|
if (this.props.queryId !== prevProps.queryId || this.props.parameter !== prevProps.parameter) {
|
||||||
this._loadOptions(this.props.queryId);
|
this._loadOptions(this.props.queryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.value !== prevProps.value) {
|
if (this.props.value !== prevProps.value) {
|
||||||
this.setValue(this.props.value);
|
this.setValue(this.props.value);
|
||||||
}
|
}
|
||||||
@@ -48,26 +59,26 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
|
|
||||||
setValue(value) {
|
setValue(value) {
|
||||||
const { options } = this.state;
|
const { options } = this.state;
|
||||||
if (this.props.mode === "multiple") {
|
const { mode, parameter } = this.props;
|
||||||
value = isArray(value) ? value : [value];
|
|
||||||
const optionValues = map(options, option => option.value);
|
if (mode === "multiple") {
|
||||||
const validValues = intersection(value, optionValues);
|
if (isNil(value)) {
|
||||||
this.setState({ value: validValues });
|
value = [];
|
||||||
return validValues;
|
|
||||||
}
|
}
|
||||||
const found = find(options, option => option.value === this.props.value) !== undefined;
|
|
||||||
value = found ? value : get(first(options), "value");
|
value = isArray(value) ? value : [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
// parameters with search don't have options available, so we trust what we get
|
||||||
|
if (!parameter.searchFunction) {
|
||||||
|
value = filterValuesThatAreNotInOptions(value, options);
|
||||||
|
}
|
||||||
|
|
||||||
this.setState({ value });
|
this.setState({ value });
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _loadOptions(queryId) {
|
updateOptions(options) {
|
||||||
if (queryId && queryId !== this.state.queryId) {
|
|
||||||
this.setState({ loading: true });
|
|
||||||
const options = await this.props.parameter.loadDropdownValues();
|
|
||||||
|
|
||||||
// stale queryId check
|
|
||||||
if (this.props.queryId === queryId) {
|
|
||||||
this.setState({ options, loading: false }, () => {
|
this.setState({ options, loading: false }, () => {
|
||||||
const updatedValue = this.setValue(this.props.value);
|
const updatedValue = this.setValue(this.props.value);
|
||||||
if (!isEqual(updatedValue, this.props.value)) {
|
if (!isEqual(updatedValue, this.props.value)) {
|
||||||
@@ -75,33 +86,59 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _loadOptions(queryId) {
|
||||||
|
if (queryId && queryId !== this.state.queryId) {
|
||||||
|
this.setState({ loading: true });
|
||||||
|
const options = await this.props.parameter.loadDropdownValues(this.state.currentSearchTerm);
|
||||||
|
|
||||||
|
// stale queryId check
|
||||||
|
if (this.props.queryId === queryId) {
|
||||||
|
this.updateOptions(options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
searchFunction = debounce(searchTerm => {
|
||||||
|
const { parameter } = this.props;
|
||||||
|
if (parameter.searchFunction && trim(searchTerm)) {
|
||||||
|
this.setState({ loading: true, currentSearchTerm: searchTerm });
|
||||||
|
parameter.searchFunction(searchTerm).then(options => {
|
||||||
|
if (this.state.currentSearchTerm === searchTerm) {
|
||||||
|
this.updateOptions(options);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, SEARCH_DEBOUNCE_TIME);
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { className, value, mode, onSelect, ...otherProps } = this.props;
|
const { parameter, className, mode, onSelect, queryId, value, ...otherProps } = this.props;
|
||||||
const { loading, options } = this.state;
|
const { loading, options } = this.state;
|
||||||
|
const selectProps = { ...otherProps };
|
||||||
|
|
||||||
|
if (parameter.searchColumn) {
|
||||||
|
selectProps.filterOption = false;
|
||||||
|
selectProps.onSearch = this.searchFunction;
|
||||||
|
selectProps.onChange = value => onSelect(parameter.normalizeValue(value));
|
||||||
|
selectProps.notFoundContent = null;
|
||||||
|
selectProps.labelInValue = true;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
<Select
|
<SelectWithVirtualScroll
|
||||||
className={className}
|
className={className}
|
||||||
disabled={loading}
|
disabled={!parameter.searchFunction && loading}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
value={this.state.value}
|
value={this.state.value || undefined}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
dropdownMatchSelectWidth={false}
|
options={options}
|
||||||
optionFilterProp="children"
|
optionFilterProp="children"
|
||||||
showSearch
|
showSearch
|
||||||
showArrow
|
showArrow
|
||||||
notFoundContent={isEmpty(options) ? "No options available" : null}
|
notFoundContent={isEmpty(options) ? "No options available" : null}
|
||||||
{...otherProps}>
|
{...selectProps}
|
||||||
{options.map(option => (
|
/>
|
||||||
<Option value={option.value} key={option.value}>
|
|
||||||
{option.name}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
38
client/app/components/SelectWithVirtualScroll.tsx
Normal file
38
client/app/components/SelectWithVirtualScroll.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { maxBy } from "lodash";
|
||||||
|
import AntdSelect, { SelectProps, LabeledValue } from "antd/lib/select";
|
||||||
|
import { calculateTextWidth } from "@/lib/calculateTextWidth";
|
||||||
|
|
||||||
|
const MIN_LEN_FOR_VIRTUAL_SCROLL = 400;
|
||||||
|
|
||||||
|
interface VirtualScrollLabeledValue extends LabeledValue {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtualScrollSelectProps extends SelectProps<string> {
|
||||||
|
options: Array<VirtualScrollLabeledValue>;
|
||||||
|
}
|
||||||
|
function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps): JSX.Element {
|
||||||
|
const dropdownMatchSelectWidth = useMemo<number | boolean>(() => {
|
||||||
|
if (options && options.length > MIN_LEN_FOR_VIRTUAL_SCROLL) {
|
||||||
|
const largestOpt = maxBy(options, "label.length");
|
||||||
|
|
||||||
|
if (largestOpt) {
|
||||||
|
const offset = 40;
|
||||||
|
const optionText = largestOpt.label;
|
||||||
|
const width = calculateTextWidth(optionText);
|
||||||
|
if (width) {
|
||||||
|
return width + offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
return <AntdSelect<string> dropdownMatchSelectWidth={dropdownMatchSelectWidth} options={options} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectWithVirtualScroll;
|
||||||
@@ -4,13 +4,13 @@ import PropTypes from "prop-types";
|
|||||||
import Tag from "antd/lib/tag";
|
import Tag from "antd/lib/tag";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
|
|
||||||
export default function UserGroups({ groups, ...props }) {
|
import "./UserGroups.less";
|
||||||
|
|
||||||
|
export default function UserGroups({ groups, linkGroups, ...props }) {
|
||||||
return (
|
return (
|
||||||
<div {...props}>
|
<div className="user-groups" {...props}>
|
||||||
{map(groups, group => (
|
{map(groups, group => (
|
||||||
<Tag className="m-b-5 m-r-5" key={group.id}>
|
<Tag key={group.id}>{linkGroups ? <Link href={`groups/${group.id}`}>{group.name}</Link> : group.name}</Tag>
|
||||||
<Link href={`groups/${group.id}`}>{group.name}</Link>
|
|
||||||
</Tag>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -19,12 +19,14 @@ export default function UserGroups({ groups, ...props }) {
|
|||||||
UserGroups.propTypes = {
|
UserGroups.propTypes = {
|
||||||
groups: PropTypes.arrayOf(
|
groups: PropTypes.arrayOf(
|
||||||
PropTypes.shape({
|
PropTypes.shape({
|
||||||
id: PropTypes.number.isRequired,
|
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
linkGroups: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
UserGroups.defaultProps = {
|
UserGroups.defaultProps = {
|
||||||
groups: [],
|
groups: [],
|
||||||
|
linkGroups: true,
|
||||||
};
|
};
|
||||||
7
client/app/components/UserGroups.less
Normal file
7
client/app/components/UserGroups.less
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.user-groups {
|
||||||
|
margin: -5px 0 0 -5px;
|
||||||
|
|
||||||
|
.ant-tag {
|
||||||
|
margin: 5px 0 0 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,6 +238,7 @@ class DashboardGrid extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ResponsiveGridLayout
|
<ResponsiveGridLayout
|
||||||
|
draggableCancel="input"
|
||||||
className={cx("layout", { "disable-animations": this.state.disableAnimations })}
|
className={cx("layout", { "disable-animations": this.state.disableAnimations })}
|
||||||
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
|
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
|
||||||
rowHeight={cfg.rowHeight - cfg.margins}
|
rowHeight={cfg.rowHeight - cfg.margins}
|
||||||
|
|||||||
@@ -48,10 +48,10 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 85px;
|
bottom: 85px;
|
||||||
right: 15px;
|
right: 0;
|
||||||
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
|
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
|
||||||
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
|
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
|
||||||
background-size: calc((100vw - 15px) / 6) 5px;
|
background-size: calc((100% + 15px) / 6) 5px;
|
||||||
background-position: -7px 1px;
|
background-position: -7px 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import classNames from "classnames";
|
import { getDynamicDateFromString } from "@/services/parameters/DateParameter";
|
||||||
import moment from "moment";
|
import DynamicDatePicker from "@/components/dynamic-parameters/DynamicDatePicker";
|
||||||
import { includes } from "lodash";
|
|
||||||
import { isDynamicDate, getDynamicDateFromString } from "@/services/parameters/DateParameter";
|
|
||||||
import DateInput from "@/components/DateInput";
|
|
||||||
import DateTimeInput from "@/components/DateTimeInput";
|
|
||||||
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
|
|
||||||
|
|
||||||
import "./DynamicParameters.less";
|
|
||||||
|
|
||||||
const DYNAMIC_DATE_OPTIONS = [
|
const DYNAMIC_DATE_OPTIONS = [
|
||||||
{
|
{
|
||||||
@@ -29,8 +22,11 @@ const DYNAMIC_DATE_OPTIONS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
class DateParameter extends React.Component {
|
function DateParameter(props) {
|
||||||
static propTypes = {
|
return <DynamicDatePicker dynamicButtonOptions={{ options: DYNAMIC_DATE_OPTIONS }} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateParameter.propTypes = {
|
||||||
type: PropTypes.string,
|
type: PropTypes.string,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
@@ -38,7 +34,7 @@ class DateParameter extends React.Component {
|
|||||||
onSelect: PropTypes.func,
|
onSelect: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
DateParameter.defaultProps = {
|
||||||
type: "",
|
type: "",
|
||||||
className: "",
|
className: "",
|
||||||
value: null,
|
value: null,
|
||||||
@@ -46,70 +42,4 @@ class DateParameter extends React.Component {
|
|||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.dateComponentRef = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
onDynamicValueSelect = dynamicValue => {
|
|
||||||
const { onSelect, parameter } = this.props;
|
|
||||||
if (dynamicValue === "static") {
|
|
||||||
const parameterValue = parameter.getExecutionValue();
|
|
||||||
if (parameterValue) {
|
|
||||||
onSelect(moment(parameterValue));
|
|
||||||
} else {
|
|
||||||
onSelect(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onSelect(dynamicValue.value);
|
|
||||||
}
|
|
||||||
// give focus to the DatePicker to get keyboard shortcuts to work
|
|
||||||
this.dateComponentRef.current.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { type, value, className, onSelect } = this.props;
|
|
||||||
const hasDynamicValue = isDynamicDate(value);
|
|
||||||
const isDateTime = includes(type, "datetime");
|
|
||||||
|
|
||||||
const additionalAttributes = {};
|
|
||||||
|
|
||||||
let DateComponent = DateInput;
|
|
||||||
if (isDateTime) {
|
|
||||||
DateComponent = DateTimeInput;
|
|
||||||
if (includes(type, "with-seconds")) {
|
|
||||||
additionalAttributes.withSeconds = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (moment.isMoment(value) || value === null) {
|
|
||||||
additionalAttributes.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasDynamicValue) {
|
|
||||||
const dynamicDate = value;
|
|
||||||
additionalAttributes.placeholder = dynamicDate && dynamicDate.name;
|
|
||||||
additionalAttributes.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="date-parameter">
|
|
||||||
<DateComponent
|
|
||||||
ref={this.dateComponentRef}
|
|
||||||
className={classNames("redash-datepicker", { "dynamic-value": hasDynamicValue }, className)}
|
|
||||||
onSelect={onSelect}
|
|
||||||
suffixIcon={null}
|
|
||||||
{...additionalAttributes}
|
|
||||||
/>
|
|
||||||
<DynamicButton
|
|
||||||
options={DYNAMIC_DATE_OPTIONS}
|
|
||||||
selectedDynamicValue={hasDynamicValue ? value : null}
|
|
||||||
enabled={hasDynamicValue}
|
|
||||||
onSelect={this.onDynamicValueSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DateParameter;
|
export default DateParameter;
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import classNames from "classnames";
|
import { includes } from "lodash";
|
||||||
import moment from "moment";
|
import { getDynamicDateRangeFromString } from "@/services/parameters/DateRangeParameter";
|
||||||
import { includes, isArray, isObject } from "lodash";
|
import DynamicDateRangePicker from "@/components/dynamic-parameters/DynamicDateRangePicker";
|
||||||
import { isDynamicDateRange, getDynamicDateRangeFromString } from "@/services/parameters/DateRangeParameter";
|
|
||||||
import DateRangeInput from "@/components/DateRangeInput";
|
|
||||||
import DateTimeRangeInput from "@/components/DateTimeRangeInput";
|
|
||||||
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
|
|
||||||
|
|
||||||
import "./DynamicParameters.less";
|
|
||||||
|
|
||||||
const DYNAMIC_DATE_OPTIONS = [
|
const DYNAMIC_DATE_OPTIONS = [
|
||||||
{
|
{
|
||||||
@@ -134,18 +128,12 @@ const DYNAMIC_DATETIME_OPTIONS = [
|
|||||||
...DYNAMIC_DATE_OPTIONS,
|
...DYNAMIC_DATE_OPTIONS,
|
||||||
];
|
];
|
||||||
|
|
||||||
const widthByType = {
|
function DateRangeParameter(props) {
|
||||||
"date-range": 294,
|
const options = includes(props.type, "datetime-range") ? DYNAMIC_DATETIME_OPTIONS : DYNAMIC_DATE_OPTIONS;
|
||||||
"datetime-range": 352,
|
return <DynamicDateRangePicker {...props} dynamicButtonOptions={{ options }} />;
|
||||||
"datetime-range-with-seconds": 382,
|
|
||||||
};
|
|
||||||
|
|
||||||
function isValidDateRangeValue(value) {
|
|
||||||
return isArray(value) && value.length === 2 && moment.isMoment(value[0]) && moment.isMoment(value[1]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class DateRangeParameter extends React.Component {
|
DateRangeParameter.propTypes = {
|
||||||
static propTypes = {
|
|
||||||
type: PropTypes.string,
|
type: PropTypes.string,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
@@ -153,7 +141,7 @@ class DateRangeParameter extends React.Component {
|
|||||||
onSelect: PropTypes.func,
|
onSelect: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
DateRangeParameter.defaultProps = {
|
||||||
type: "",
|
type: "",
|
||||||
className: "",
|
className: "",
|
||||||
value: null,
|
value: null,
|
||||||
@@ -161,71 +149,4 @@ class DateRangeParameter extends React.Component {
|
|||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.dateRangeComponentRef = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
onDynamicValueSelect = dynamicValue => {
|
|
||||||
const { onSelect, parameter } = this.props;
|
|
||||||
if (dynamicValue === "static") {
|
|
||||||
const parameterValue = parameter.getExecutionValue();
|
|
||||||
if (isObject(parameterValue) && parameterValue.start && parameterValue.end) {
|
|
||||||
onSelect([moment(parameterValue.start), moment(parameterValue.end)]);
|
|
||||||
} else {
|
|
||||||
onSelect(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onSelect(dynamicValue.value);
|
|
||||||
}
|
|
||||||
// give focus to the DatePicker to get keyboard shortcuts to work
|
|
||||||
this.dateRangeComponentRef.current.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { type, value, onSelect, className } = this.props;
|
|
||||||
const isDateTimeRange = includes(type, "datetime-range");
|
|
||||||
const hasDynamicValue = isDynamicDateRange(value);
|
|
||||||
const options = isDateTimeRange ? DYNAMIC_DATETIME_OPTIONS : DYNAMIC_DATE_OPTIONS;
|
|
||||||
|
|
||||||
const additionalAttributes = {};
|
|
||||||
|
|
||||||
let DateRangeComponent = DateRangeInput;
|
|
||||||
if (isDateTimeRange) {
|
|
||||||
DateRangeComponent = DateTimeRangeInput;
|
|
||||||
if (includes(type, "with-seconds")) {
|
|
||||||
additionalAttributes.withSeconds = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValidDateRangeValue(value) || value === null) {
|
|
||||||
additionalAttributes.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasDynamicValue) {
|
|
||||||
additionalAttributes.placeholder = [value && value.name];
|
|
||||||
additionalAttributes.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="data-range-parameter">
|
|
||||||
<DateRangeComponent
|
|
||||||
ref={this.dateRangeComponentRef}
|
|
||||||
className={classNames("redash-datepicker date-range-input", { "dynamic-value": hasDynamicValue }, className)}
|
|
||||||
onSelect={onSelect}
|
|
||||||
style={{ width: hasDynamicValue ? 195 : widthByType[type] }}
|
|
||||||
suffixIcon={null}
|
|
||||||
{...additionalAttributes}
|
|
||||||
/>
|
|
||||||
<DynamicButton
|
|
||||||
options={options}
|
|
||||||
selectedDynamicValue={hasDynamicValue ? value : null}
|
|
||||||
enabled={hasDynamicValue}
|
|
||||||
onSelect={this.onDynamicValueSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DateRangeParameter;
|
export default DateRangeParameter;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import "./DynamicButton.less";
|
|||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
|
function DynamicButton({ options, selectedDynamicValue, onSelect, enabled, staticValueLabel }) {
|
||||||
const menu = (
|
const menu = (
|
||||||
<Menu
|
<Menu
|
||||||
className="dynamic-menu"
|
className="dynamic-menu"
|
||||||
@@ -32,7 +32,7 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
|
|||||||
{enabled && (
|
{enabled && (
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<ArrowLeftOutlinedIcon />
|
<ArrowLeftOutlinedIcon />
|
||||||
<Text type="secondary">Back to Static Value</Text>
|
<Text type="secondary">{staticValueLabel}</Text>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
@@ -68,6 +68,7 @@ DynamicButton.propTypes = {
|
|||||||
selectedDynamicValue: PropTypes.oneOfType([DynamicDateType, DynamicDateRangeType]),
|
selectedDynamicValue: PropTypes.oneOfType([DynamicDateType, DynamicDateRangeType]),
|
||||||
onSelect: PropTypes.func,
|
onSelect: PropTypes.func,
|
||||||
enabled: PropTypes.bool,
|
enabled: PropTypes.bool,
|
||||||
|
staticValueLabel: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
DynamicButton.defaultProps = {
|
DynamicButton.defaultProps = {
|
||||||
@@ -75,6 +76,7 @@ DynamicButton.defaultProps = {
|
|||||||
selectedDynamicValue: null,
|
selectedDynamicValue: null,
|
||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
staticValueLabel: "Back to Static Value",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DynamicButton;
|
export default DynamicButton;
|
||||||
|
|||||||
@@ -34,3 +34,9 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dynamic-icon {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|||||||
112
client/app/components/dynamic-parameters/DynamicDatePicker.jsx
Normal file
112
client/app/components/dynamic-parameters/DynamicDatePicker.jsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import moment from "moment";
|
||||||
|
import { includes } from "lodash";
|
||||||
|
import { isDynamicDate } from "@/services/parameters/DateParameter";
|
||||||
|
import DateInput from "@/components/DateInput";
|
||||||
|
import DateTimeInput from "@/components/DateTimeInput";
|
||||||
|
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
|
||||||
|
|
||||||
|
import "./DynamicParameters.less";
|
||||||
|
|
||||||
|
class DynamicDatePicker extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
type: PropTypes.string,
|
||||||
|
className: PropTypes.string,
|
||||||
|
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
|
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
|
onSelect: PropTypes.func,
|
||||||
|
dynamicButtonOptions: PropTypes.shape({
|
||||||
|
staticValueLabel: PropTypes.string,
|
||||||
|
options: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
value: PropTypes.object,
|
||||||
|
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
dateOptions: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
type: "",
|
||||||
|
className: "",
|
||||||
|
value: null,
|
||||||
|
parameter: null,
|
||||||
|
dynamicButtonOptions: {
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
onSelect: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.dateComponentRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDynamicValueSelect = dynamicValue => {
|
||||||
|
const { onSelect, parameter } = this.props;
|
||||||
|
if (dynamicValue === "static") {
|
||||||
|
const parameterValue = parameter.getExecutionValue();
|
||||||
|
if (parameterValue) {
|
||||||
|
onSelect(moment(parameterValue));
|
||||||
|
} else {
|
||||||
|
onSelect(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onSelect(dynamicValue.value);
|
||||||
|
}
|
||||||
|
// give focus to the DatePicker to get keyboard shortcuts to work
|
||||||
|
this.dateComponentRef.current.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { type, value, className, dateOptions, dynamicButtonOptions, onSelect } = this.props;
|
||||||
|
const hasDynamicValue = isDynamicDate(value);
|
||||||
|
const isDateTime = includes(type, "datetime");
|
||||||
|
|
||||||
|
const additionalAttributes = {};
|
||||||
|
|
||||||
|
let DateComponent = DateInput;
|
||||||
|
if (isDateTime) {
|
||||||
|
DateComponent = DateTimeInput;
|
||||||
|
if (includes(type, "with-seconds")) {
|
||||||
|
additionalAttributes.withSeconds = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moment.isMoment(value) || value === null) {
|
||||||
|
additionalAttributes.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDynamicValue) {
|
||||||
|
const dynamicDate = value;
|
||||||
|
additionalAttributes.placeholder = dynamicDate && dynamicDate.name;
|
||||||
|
additionalAttributes.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames("date-parameter", className)}>
|
||||||
|
<DateComponent
|
||||||
|
{...dateOptions}
|
||||||
|
ref={this.dateComponentRef}
|
||||||
|
className={classNames("redash-datepicker", type, { "dynamic-value": hasDynamicValue })}
|
||||||
|
onSelect={onSelect}
|
||||||
|
suffixIcon={null}
|
||||||
|
{...additionalAttributes}
|
||||||
|
/>
|
||||||
|
<DynamicButton
|
||||||
|
options={dynamicButtonOptions.options}
|
||||||
|
staticValueLabel={dynamicButtonOptions.staticValueLabel}
|
||||||
|
selectedDynamicValue={hasDynamicValue ? value : null}
|
||||||
|
enabled={hasDynamicValue}
|
||||||
|
onSelect={this.onDynamicValueSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DynamicDatePicker;
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import moment from "moment";
|
||||||
|
import { includes, isArray, isObject } from "lodash";
|
||||||
|
import { isDynamicDateRange } from "@/services/parameters/DateRangeParameter";
|
||||||
|
import DateRangeInput from "@/components/DateRangeInput";
|
||||||
|
import DateTimeRangeInput from "@/components/DateTimeRangeInput";
|
||||||
|
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
|
||||||
|
|
||||||
|
import "./DynamicParameters.less";
|
||||||
|
|
||||||
|
function isValidDateRangeValue(value) {
|
||||||
|
return isArray(value) && value.length === 2 && moment.isMoment(value[0]) && moment.isMoment(value[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DynamicDateRangePicker extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
type: PropTypes.oneOf(["date-range", "datetime-range", "datetime-range-with-seconds"]).isRequired,
|
||||||
|
className: PropTypes.string,
|
||||||
|
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
|
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
|
onSelect: PropTypes.func,
|
||||||
|
dynamicButtonOptions: PropTypes.shape({
|
||||||
|
staticValueLabel: PropTypes.string,
|
||||||
|
options: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
value: PropTypes.object,
|
||||||
|
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
dateRangeOptions: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
type: "date-range",
|
||||||
|
className: "",
|
||||||
|
value: null,
|
||||||
|
parameter: null,
|
||||||
|
dynamicButtonOptions: {
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
onSelect: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.dateRangeComponentRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDynamicValueSelect = dynamicValue => {
|
||||||
|
const { onSelect, parameter } = this.props;
|
||||||
|
if (dynamicValue === "static") {
|
||||||
|
const parameterValue = parameter.getExecutionValue();
|
||||||
|
if (isObject(parameterValue) && parameterValue.start && parameterValue.end) {
|
||||||
|
onSelect([moment(parameterValue.start), moment(parameterValue.end)]);
|
||||||
|
} else {
|
||||||
|
onSelect(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onSelect(dynamicValue.value);
|
||||||
|
}
|
||||||
|
// give focus to the DatePicker to get keyboard shortcuts to work
|
||||||
|
this.dateRangeComponentRef.current.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { type, value, onSelect, className, dynamicButtonOptions, dateRangeOptions, parameter, ...rest } = this.props;
|
||||||
|
const isDateTimeRange = includes(type, "datetime-range");
|
||||||
|
const hasDynamicValue = isDynamicDateRange(value);
|
||||||
|
|
||||||
|
const additionalAttributes = {};
|
||||||
|
|
||||||
|
let DateRangeComponent = DateRangeInput;
|
||||||
|
if (isDateTimeRange) {
|
||||||
|
DateRangeComponent = DateTimeRangeInput;
|
||||||
|
if (includes(type, "with-seconds")) {
|
||||||
|
additionalAttributes.withSeconds = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidDateRangeValue(value) || value === null) {
|
||||||
|
additionalAttributes.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDynamicValue) {
|
||||||
|
additionalAttributes.placeholder = [value && value.name];
|
||||||
|
additionalAttributes.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...rest} className={classNames("date-range-parameter", className)}>
|
||||||
|
<DateRangeComponent
|
||||||
|
{...dateRangeOptions}
|
||||||
|
ref={this.dateRangeComponentRef}
|
||||||
|
className={classNames("redash-datepicker date-range-input", type, { "dynamic-value": hasDynamicValue })}
|
||||||
|
onSelect={onSelect}
|
||||||
|
suffixIcon={null}
|
||||||
|
{...additionalAttributes}
|
||||||
|
/>
|
||||||
|
<DynamicButton
|
||||||
|
options={dynamicButtonOptions.options}
|
||||||
|
staticValueLabel={dynamicButtonOptions.staticValueLabel}
|
||||||
|
selectedDynamicValue={hasDynamicValue ? value : null}
|
||||||
|
enabled={hasDynamicValue}
|
||||||
|
onSelect={this.onDynamicValueSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DynamicDateRangePicker;
|
||||||
@@ -1,8 +1,26 @@
|
|||||||
@import "../../assets/less/inc/variables";
|
@import "../../assets/less/inc/variables";
|
||||||
|
|
||||||
|
.date-range-parameter,
|
||||||
|
.date-parameter {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.redash-datepicker {
|
.redash-datepicker {
|
||||||
padding-right: 35px !important;
|
padding-right: 35px !important;
|
||||||
|
|
||||||
|
&.date-range {
|
||||||
|
width: 294px;
|
||||||
|
}
|
||||||
|
&.datetime-range {
|
||||||
|
width: 352px;
|
||||||
|
}
|
||||||
|
&.datetime-range-with-seconds {
|
||||||
|
width: 382px;
|
||||||
|
}
|
||||||
|
&.dynamic-value {
|
||||||
|
width: 195px;
|
||||||
|
}
|
||||||
|
|
||||||
&.ant-picker-range .ant-picker-clear {
|
&.ant-picker-range .ant-picker-clear {
|
||||||
right: 35px !important;
|
right: 35px !important;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -14,7 +32,7 @@
|
|||||||
|
|
||||||
&.dynamic-value {
|
&.dynamic-value {
|
||||||
& ::placeholder {
|
& ::placeholder {
|
||||||
color: @text-color !important;
|
color: @input-color !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.date-range-input {
|
&.date-range-input {
|
||||||
@@ -22,7 +40,8 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-picker-separator {
|
.ant-picker-separator,
|
||||||
|
.ant-picker-range-separator {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ export interface StepItem<K> {
|
|||||||
node: React.ReactNode;
|
node: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmptyStateHelpMessageProps {
|
||||||
|
helpTriggerType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare const EmptyStateHelpMessage: React.FunctionComponent<EmptyStateHelpMessageProps>;
|
||||||
|
|
||||||
export interface EmptyStateProps<K = unknown> {
|
export interface EmptyStateProps<K = unknown> {
|
||||||
header?: string;
|
header?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
description: string;
|
description: string;
|
||||||
illustration: string;
|
illustration: string;
|
||||||
illustrationPath?: string;
|
illustrationPath?: string;
|
||||||
helpLink: string;
|
helpMessage?: React.ReactNode;
|
||||||
|
closable?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
|
||||||
onboardingMode?: boolean;
|
onboardingMode?: boolean;
|
||||||
showAlertStep?: boolean;
|
showAlertStep?: boolean;
|
||||||
@@ -33,8 +41,9 @@ export interface StepProps {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
url?: string;
|
url?: string;
|
||||||
urlText?: string;
|
urlTarget?: string;
|
||||||
text: string;
|
urlText?: React.ReactNode;
|
||||||
|
text?: React.ReactNode;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,22 @@ import { keys, some } from "lodash";
|
|||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||||
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
import { currentUser } from "@/services/auth";
|
import { currentUser } from "@/services/auth";
|
||||||
import organizationStatus from "@/services/organizationStatus";
|
import organizationStatus from "@/services/organizationStatus";
|
||||||
import "./empty-state.less";
|
import "./empty-state.less";
|
||||||
|
|
||||||
export function Step({ show, completed, text, url, urlText, onClick }) {
|
export function Step({ show, completed, text, url, urlTarget, urlText, onClick }) {
|
||||||
if (!show) {
|
if (!show) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={classNames({ done: completed })}>
|
<li className={classNames({ done: completed })}>
|
||||||
<Link href={url} onClick={onClick}>
|
<Link href={url} onClick={onClick} target={urlTarget}>
|
||||||
{urlText}
|
{urlText}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
{text}
|
{text}
|
||||||
@@ -26,24 +28,44 @@ export function Step({ show, completed, text, url, urlText, onClick }) {
|
|||||||
Step.propTypes = {
|
Step.propTypes = {
|
||||||
show: PropTypes.bool.isRequired,
|
show: PropTypes.bool.isRequired,
|
||||||
completed: PropTypes.bool.isRequired,
|
completed: PropTypes.bool.isRequired,
|
||||||
text: PropTypes.string.isRequired,
|
text: PropTypes.node,
|
||||||
url: PropTypes.string,
|
url: PropTypes.string,
|
||||||
urlText: PropTypes.string,
|
urlTarget: PropTypes.string,
|
||||||
|
urlText: PropTypes.node,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
Step.defaultProps = {
|
Step.defaultProps = {
|
||||||
url: null,
|
url: null,
|
||||||
|
urlTarget: null,
|
||||||
urlText: null,
|
urlText: null,
|
||||||
|
text: null,
|
||||||
onClick: null,
|
onClick: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function EmptyStateHelpMessage({ helpTriggerType }) {
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Need more support?{" "}
|
||||||
|
<HelpTrigger className="f-14" type={helpTriggerType} showTooltip={false}>
|
||||||
|
See our Help
|
||||||
|
</HelpTrigger>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EmptyStateHelpMessage.propTypes = {
|
||||||
|
helpTriggerType: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
function EmptyState({
|
function EmptyState({
|
||||||
icon,
|
icon,
|
||||||
header,
|
header,
|
||||||
description,
|
description,
|
||||||
illustration,
|
illustration,
|
||||||
helpLink,
|
helpMessage,
|
||||||
|
closable,
|
||||||
|
onClose,
|
||||||
onboardingMode,
|
onboardingMode,
|
||||||
showAlertStep,
|
showAlertStep,
|
||||||
showDashboardStep,
|
showDashboardStep,
|
||||||
@@ -87,8 +109,7 @@ function EmptyState({
|
|||||||
show={isAvailable.dataSource}
|
show={isAvailable.dataSource}
|
||||||
completed={isCompleted.dataSource}
|
completed={isCompleted.dataSource}
|
||||||
url="data_sources/new"
|
url="data_sources/new"
|
||||||
urlText="Connect"
|
urlText="Connect a Data Source"
|
||||||
text="a Data Source"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -116,8 +137,7 @@ function EmptyState({
|
|||||||
show={isAvailable.query}
|
show={isAvailable.query}
|
||||||
completed={isCompleted.query}
|
completed={isCompleted.query}
|
||||||
url="queries/new"
|
url="queries/new"
|
||||||
urlText="Create"
|
urlText="Create your first Query"
|
||||||
text="your first Query"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -129,8 +149,7 @@ function EmptyState({
|
|||||||
show={isAvailable.alert}
|
show={isAvailable.alert}
|
||||||
completed={isCompleted.alert}
|
completed={isCompleted.alert}
|
||||||
url="alerts/new"
|
url="alerts/new"
|
||||||
urlText="Create"
|
urlText="Create your first Alert"
|
||||||
text="your first Alert"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -142,8 +161,7 @@ function EmptyState({
|
|||||||
show={isAvailable.dashboard}
|
show={isAvailable.dashboard}
|
||||||
completed={isCompleted.dashboard}
|
completed={isCompleted.dashboard}
|
||||||
onClick={showCreateDashboardDialog}
|
onClick={showCreateDashboardDialog}
|
||||||
urlText="Create"
|
urlText="Create your first Dashboard"
|
||||||
text="your first Dashboard"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -155,8 +173,7 @@ function EmptyState({
|
|||||||
show={isAvailable.inviteUsers}
|
show={isAvailable.inviteUsers}
|
||||||
completed={isCompleted.inviteUsers}
|
completed={isCompleted.inviteUsers}
|
||||||
url="users/new"
|
url="users/new"
|
||||||
urlText="Invite"
|
urlText="Invite your team members"
|
||||||
text="your team members"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -166,6 +183,7 @@ function EmptyState({
|
|||||||
const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg";
|
const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="empty-state-wrapper">
|
||||||
<div className="empty-state bg-white tiled">
|
<div className="empty-state bg-white tiled">
|
||||||
<div className="empty-state__summary">
|
<div className="empty-state__summary">
|
||||||
{header && <h4>{header}</h4>}
|
{header && <h4>{header}</h4>}
|
||||||
@@ -178,15 +196,15 @@ function EmptyState({
|
|||||||
<div className="empty-state__steps">
|
<div className="empty-state__steps">
|
||||||
<h4>Let's get started</h4>
|
<h4>Let's get started</h4>
|
||||||
<ol>{stepsItems.map(item => item.node)}</ol>
|
<ol>{stepsItems.map(item => item.node)}</ol>
|
||||||
<p>
|
{helpMessage}
|
||||||
Need more support?{" "}
|
|
||||||
<Link href={helpLink} target="_blank" rel="noopener noreferrer">
|
|
||||||
See our Help
|
|
||||||
<i className="fa fa-external-link m-l-5" aria-hidden="true" />
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{closable && (
|
||||||
|
<a className="close-button" onClick={onClose}>
|
||||||
|
<CloseOutlinedIcon />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +214,9 @@ EmptyState.propTypes = {
|
|||||||
description: PropTypes.string.isRequired,
|
description: PropTypes.string.isRequired,
|
||||||
illustration: PropTypes.string.isRequired,
|
illustration: PropTypes.string.isRequired,
|
||||||
illustrationPath: PropTypes.string,
|
illustrationPath: PropTypes.string,
|
||||||
helpLink: PropTypes.string.isRequired,
|
helpMessage: PropTypes.node,
|
||||||
|
closable: PropTypes.bool,
|
||||||
|
onClose: PropTypes.func,
|
||||||
|
|
||||||
onboardingMode: PropTypes.bool,
|
onboardingMode: PropTypes.bool,
|
||||||
showAlertStep: PropTypes.bool,
|
showAlertStep: PropTypes.bool,
|
||||||
@@ -210,6 +230,9 @@ EmptyState.propTypes = {
|
|||||||
EmptyState.defaultProps = {
|
EmptyState.defaultProps = {
|
||||||
icon: null,
|
icon: null,
|
||||||
header: null,
|
header: null,
|
||||||
|
helpMessage: null,
|
||||||
|
closable: false,
|
||||||
|
onClose: () => {},
|
||||||
|
|
||||||
onboardingMode: false,
|
onboardingMode: false,
|
||||||
showAlertStep: false,
|
showAlertStep: false,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import (reference, less) "~@/assets/less/ant";
|
||||||
|
|
||||||
// Empty states
|
// Empty states
|
||||||
.empty-state {
|
.empty-state {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -19,11 +21,14 @@
|
|||||||
padding-left: 0px;
|
padding-left: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.empty-state__summary {
|
.empty-state__summary {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: rgba(102, 136, 153, 0.025);
|
background: rgba(102, 136, 153, 0.025);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ol {
|
ol {
|
||||||
@@ -44,10 +49,6 @@
|
|||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -71,3 +72,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// close button
|
||||||
|
.empty-state-wrapper {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 25px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color @animation-duration-slow;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @text-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ export const Columns = {
|
|||||||
overrides
|
overrides
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
timeAgo(overrides) {
|
timeAgo(overrides, timeAgoCustomProps = undefined) {
|
||||||
return extend(
|
return extend(
|
||||||
{
|
{
|
||||||
render: value => <TimeAgo date={value} />,
|
render: value => <TimeAgo date={value} {...timeAgoCustomProps} />,
|
||||||
},
|
},
|
||||||
overrides
|
overrides
|
||||||
);
|
);
|
||||||
@@ -110,6 +110,8 @@ export default class ItemsTable extends React.Component {
|
|||||||
orderByField: PropTypes.string,
|
orderByField: PropTypes.string,
|
||||||
orderByReverse: PropTypes.bool,
|
orderByReverse: PropTypes.bool,
|
||||||
toggleSorting: PropTypes.func,
|
toggleSorting: PropTypes.func,
|
||||||
|
"data-test": PropTypes.string,
|
||||||
|
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@@ -151,6 +153,17 @@ export default class ItemsTable extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRowKey = record => {
|
||||||
|
const { rowKey } = this.props;
|
||||||
|
if (rowKey) {
|
||||||
|
if (isFunction(rowKey)) {
|
||||||
|
return rowKey(record.item);
|
||||||
|
}
|
||||||
|
return record.item[rowKey];
|
||||||
|
}
|
||||||
|
return record.key;
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const tableDataProps = {
|
const tableDataProps = {
|
||||||
columns: this.prepareColumns(),
|
columns: this.prepareColumns(),
|
||||||
@@ -184,9 +197,10 @@ export default class ItemsTable extends React.Component {
|
|||||||
<Table
|
<Table
|
||||||
className={classNames("table-data", { "ant-table-headerless": !showHeader })}
|
className={classNames("table-data", { "ant-table-headerless": !showHeader })}
|
||||||
showHeader={showHeader}
|
showHeader={showHeader}
|
||||||
rowKey={row => row.key}
|
rowKey={this.getRowKey}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
onRow={onTableRow}
|
onRow={onTableRow}
|
||||||
|
data-test={this.props["data-test"]}
|
||||||
{...tableDataProps}
|
{...tableDataProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { filter, includes, intersection } from "lodash";
|
||||||
|
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||||
|
import Checkbox from "antd/lib/checkbox";
|
||||||
|
import { Columns } from "../components/ItemsTable";
|
||||||
|
|
||||||
|
export default function useItemsListExtraActions(controller, listColumns, ExtraActionsComponent) {
|
||||||
|
const [actionsState, setActionsState] = useState({ isAvailable: false });
|
||||||
|
const [selectedItems, setSelectedItems] = useState([]);
|
||||||
|
|
||||||
|
// clear selection when page changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedItems([]);
|
||||||
|
}, [controller.pageItems, actionsState.isAvailable]);
|
||||||
|
|
||||||
|
const areAllItemsSelected = useMemo(() => {
|
||||||
|
const allItems = controller.pageItems;
|
||||||
|
if (allItems.length === 0 || selectedItems.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return intersection(selectedItems, allItems).length === allItems.length;
|
||||||
|
}, [selectedItems, controller.pageItems]);
|
||||||
|
|
||||||
|
const toggleAllItems = useCallback(() => {
|
||||||
|
if (areAllItemsSelected) {
|
||||||
|
setSelectedItems([]);
|
||||||
|
} else {
|
||||||
|
setSelectedItems(controller.pageItems);
|
||||||
|
}
|
||||||
|
}, [areAllItemsSelected, controller.pageItems]);
|
||||||
|
|
||||||
|
const toggleItem = useCallback(
|
||||||
|
item => {
|
||||||
|
if (includes(selectedItems, item)) {
|
||||||
|
setSelectedItems(filter(selectedItems, s => s !== item));
|
||||||
|
} else {
|
||||||
|
setSelectedItems([...selectedItems, item]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkboxColumn = useMemo(
|
||||||
|
() =>
|
||||||
|
Columns.custom(
|
||||||
|
(text, item) => <Checkbox checked={includes(selectedItems, item)} onChange={() => toggleItem(item)} />,
|
||||||
|
{
|
||||||
|
title: () => <Checkbox checked={areAllItemsSelected} onChange={toggleAllItems} />,
|
||||||
|
field: "id",
|
||||||
|
width: "1%",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
[selectedItems, areAllItemsSelected, toggleAllItems, toggleItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
const Component = useCallback(
|
||||||
|
function ItemsListExtraActionsComponentWrapper(props) {
|
||||||
|
// this check mostly needed to avoid eslint exhaustive deps warning
|
||||||
|
if (!ExtraActionsComponent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ExtraActionsComponent onStateChange={setActionsState} {...props} />;
|
||||||
|
},
|
||||||
|
[ExtraActionsComponent]
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
areExtraActionsAvailable: actionsState.isAvailable,
|
||||||
|
listColumns: actionsState.isAvailable ? [checkboxColumn, ...listColumns] : listColumns,
|
||||||
|
Component,
|
||||||
|
selectedItems,
|
||||||
|
setSelectedItems,
|
||||||
|
}),
|
||||||
|
[actionsState, listColumns, checkboxColumn, selectedItems, Component]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { clientConfig } from "@/services/auth";
|
|||||||
import notification from "@/services/notification";
|
import notification from "@/services/notification";
|
||||||
|
|
||||||
import "./index.less";
|
import "./index.less";
|
||||||
|
import { policy } from "@/services/policy";
|
||||||
|
|
||||||
function ApiKeyDialog({ dialog, ...props }) {
|
function ApiKeyDialog({ dialog, ...props }) {
|
||||||
const [query, setQuery] = useState(props.query);
|
const [query, setQuery] = useState(props.query);
|
||||||
@@ -45,7 +46,7 @@ function ApiKeyDialog({ dialog, ...props }) {
|
|||||||
<div className="m-b-20">
|
<div className="m-b-20">
|
||||||
<Input.Group compact>
|
<Input.Group compact>
|
||||||
<Input readOnly value={query.api_key} />
|
<Input readOnly value={query.api_key} />
|
||||||
{query.can_edit && (
|
{policy.canEdit(query) && (
|
||||||
<Button disabled={updatingApiKey} loading={updatingApiKey} onClick={regenerateQueryApiKey}>
|
<Button disabled={updatingApiKey} loading={updatingApiKey} onClick={regenerateQueryApiKey}>
|
||||||
Regenerate
|
Regenerate
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import DatePicker from "antd/lib/date-picker";
|
|||||||
import TimePicker from "antd/lib/time-picker";
|
import TimePicker from "antd/lib/time-picker";
|
||||||
import Select from "antd/lib/select";
|
import Select from "antd/lib/select";
|
||||||
import Radio from "antd/lib/radio";
|
import Radio from "antd/lib/radio";
|
||||||
import { capitalize, clone, isEqual, omitBy, isNil } from "lodash";
|
import { capitalize, clone, isEqual, omitBy, isNil, isEmpty } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { secondsToInterval, durationHumanize, pluralize, IntervalEnum, localizeTime } from "@/lib/utils";
|
import { secondsToInterval, durationHumanize, pluralize, IntervalEnum, localizeTime } from "@/lib/utils";
|
||||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||||
@@ -207,7 +207,9 @@ class ScheduleDialog extends React.Component {
|
|||||||
<Option value={null} key="never">
|
<Option value={null} key="never">
|
||||||
Never
|
Never
|
||||||
</Option>
|
</Option>
|
||||||
{Object.keys(this.intervals).map(int => (
|
{Object.keys(this.intervals)
|
||||||
|
.filter(int => !isEmpty(this.intervals[int]))
|
||||||
|
.map(int => (
|
||||||
<OptGroup label={capitalize(pluralize(int))} key={int}>
|
<OptGroup label={capitalize(pluralize(int))} key={int}>
|
||||||
{this.intervals[int].map(([cnt, secs]) => (
|
{this.intervals[int].map(([cnt, secs]) => (
|
||||||
<Option value={secs} key={`${int}-${cnt}`}>
|
<Option value={secs} key={`${int}-${cnt}`}>
|
||||||
|
|||||||
@@ -41,19 +41,24 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tableDisplayName = item.displayName || item.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...props}>
|
<div {...props}>
|
||||||
<div className="table-name" onClick={onToggle}>
|
<div className="table-name" onClick={onToggle}>
|
||||||
<i className="fa fa-table m-r-5" />
|
<i className="fa fa-table m-r-5" />
|
||||||
<strong>
|
<strong>
|
||||||
<span title={item.name}>{item.name}</span>
|
<span title={item.name}>{tableDisplayName}</span>
|
||||||
{!isNil(item.size) && <span> ({item.size})</span>}
|
{!isNil(item.size) && <span> ({item.size})</span>}
|
||||||
</strong>
|
</strong>
|
||||||
|
|
||||||
|
<Tooltip title="Insert table name into query text" mouseEnterDelay={0} mouseLeaveDelay={0}>
|
||||||
<i
|
<i
|
||||||
className="fa fa-angle-double-right copy-to-editor"
|
className="fa fa-angle-double-right copy-to-editor"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onClick={e => handleSelect(e, item.name)}
|
onClick={e => handleSelect(e, item.name)}
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div>
|
<div>
|
||||||
@@ -66,11 +71,13 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
|
|||||||
return (
|
return (
|
||||||
<div key={columnName} className="table-open">
|
<div key={columnName} className="table-open">
|
||||||
{columnName} {columnType && <span className="column-type">{columnType}</span>}
|
{columnName} {columnType && <span className="column-type">{columnType}</span>}
|
||||||
|
<Tooltip title="Insert column name into query text" mouseEnterDelay={0} mouseLeaveDelay={0}>
|
||||||
<i
|
<i
|
||||||
className="fa fa-angle-double-right copy-to-editor"
|
className="fa fa-angle-double-right copy-to-editor"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onClick={e => handleSelect(e, columnName)}
|
onClick={e => handleSelect(e, columnName)}
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -84,15 +84,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "1 Day 22:15" Set
|
|||||||
"groupOption": false,
|
"groupOption": false,
|
||||||
"key": "never",
|
"key": "never",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"data": Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
"group": true,
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
@@ -309,11 +300,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "1 Day 22:15" Set
|
|||||||
"key": "never",
|
"key": "never",
|
||||||
"value": null,
|
"value": null,
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
"label": "Minutes",
|
"label": "Minutes",
|
||||||
@@ -502,15 +488,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "1 Day 22:15" Set
|
|||||||
"groupOption": false,
|
"groupOption": false,
|
||||||
"key": "never",
|
"key": "never",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"data": Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
"group": true,
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
@@ -727,11 +704,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "1 Day 22:15" Set
|
|||||||
"key": "never",
|
"key": "never",
|
||||||
"value": null,
|
"value": null,
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
"label": "Minutes",
|
"label": "Minutes",
|
||||||
@@ -2057,15 +2029,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Hours" 1`] = `
|
|||||||
"groupOption": false,
|
"groupOption": false,
|
||||||
"key": "never",
|
"key": "never",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"data": Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
"group": true,
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
@@ -2282,11 +2245,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Hours" 1`] = `
|
|||||||
"key": "never",
|
"key": "never",
|
||||||
"value": null,
|
"value": null,
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
"label": "Minutes",
|
"label": "Minutes",
|
||||||
@@ -2475,15 +2433,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Hours" 1`] = `
|
|||||||
"groupOption": false,
|
"groupOption": false,
|
||||||
"key": "never",
|
"key": "never",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"data": Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
"group": true,
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
@@ -2700,11 +2649,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Hours" 1`] = `
|
|||||||
"key": "never",
|
"key": "never",
|
||||||
"value": null,
|
"value": null,
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
"label": "Minutes",
|
"label": "Minutes",
|
||||||
@@ -3234,15 +3178,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Weeks 22:15 Tu
|
|||||||
"groupOption": false,
|
"groupOption": false,
|
||||||
"key": "never",
|
"key": "never",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"data": Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
"group": true,
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
@@ -3459,11 +3394,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Weeks 22:15 Tu
|
|||||||
"key": "never",
|
"key": "never",
|
||||||
"value": null,
|
"value": null,
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
"label": "Minutes",
|
"label": "Minutes",
|
||||||
@@ -3652,15 +3582,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Weeks 22:15 Tu
|
|||||||
"groupOption": false,
|
"groupOption": false,
|
||||||
"key": "never",
|
"key": "never",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"data": Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
"group": true,
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
@@ -3877,11 +3798,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Weeks 22:15 Tu
|
|||||||
"key": "never",
|
"key": "never",
|
||||||
"value": null,
|
"value": null,
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
"label": "Minutes",
|
"label": "Minutes",
|
||||||
@@ -5581,15 +5497,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "5 Minutes" 1`] =
|
|||||||
"groupOption": false,
|
"groupOption": false,
|
||||||
"key": "never",
|
"key": "never",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"data": Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
"group": true,
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
@@ -5806,11 +5713,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "5 Minutes" 1`] =
|
|||||||
"key": "never",
|
"key": "never",
|
||||||
"value": null,
|
"value": null,
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
"label": "Minutes",
|
"label": "Minutes",
|
||||||
@@ -5999,15 +5901,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "5 Minutes" 1`] =
|
|||||||
"groupOption": false,
|
"groupOption": false,
|
||||||
"key": "never",
|
"key": "never",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"data": Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
"group": true,
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
@@ -6224,11 +6117,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "5 Minutes" 1`] =
|
|||||||
"key": "never",
|
"key": "never",
|
||||||
"value": null,
|
"value": null,
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
"label": "Minutes",
|
"label": "Minutes",
|
||||||
@@ -6755,15 +6643,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "Never" 1`] = `
|
|||||||
"groupOption": false,
|
"groupOption": false,
|
||||||
"key": "never",
|
"key": "never",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"data": Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
"group": true,
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
@@ -6980,11 +6859,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "Never" 1`] = `
|
|||||||
"key": "never",
|
"key": "never",
|
||||||
"value": null,
|
"value": null,
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
"label": "Minutes",
|
"label": "Minutes",
|
||||||
@@ -7169,15 +7043,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "Never" 1`] = `
|
|||||||
"groupOption": false,
|
"groupOption": false,
|
||||||
"key": "never",
|
"key": "never",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"data": Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
"group": true,
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
@@ -7394,11 +7259,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "Never" 1`] = `
|
|||||||
"key": "never",
|
"key": "never",
|
||||||
"value": null,
|
"value": null,
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"key": "__RC_SELECT_GRP__Never__",
|
|
||||||
"label": "Nevers",
|
|
||||||
"options": Array [],
|
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"key": "__RC_SELECT_GRP__minute__",
|
"key": "__RC_SELECT_GRP__minute__",
|
||||||
"label": "Minutes",
|
"label": "Minutes",
|
||||||
|
|||||||
@@ -67,10 +67,6 @@ export default function DatabricksSchemaBrowser({
|
|||||||
setExpandedFlags({});
|
setExpandedFlags({});
|
||||||
}, [currentDatabaseName]);
|
}, [currentDatabaseName]);
|
||||||
|
|
||||||
if (schema.length === 0 && databases.length === 0 && !(loadingDatabases || loadingSchema)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTable(tableName) {
|
function toggleTable(tableName) {
|
||||||
const table = find(schema, { name: tableName });
|
const table = find(schema, { name: tableName });
|
||||||
if (!expandedFlags[tableName] && get(table, "loading", false)) {
|
if (!expandedFlags[tableName] && get(table, "loading", false)) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { has, get, map, first, isFunction, isEmpty } from "lodash";
|
import { includes, has, get, map, first, isFunction, isEmpty, startsWith } from "lodash";
|
||||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||||
import notification from "@/services/notification";
|
import notification from "@/services/notification";
|
||||||
import DatabricksDataSource from "@/services/databricks-data-source";
|
import DatabricksDataSource from "@/services/databricks-data-source";
|
||||||
@@ -9,7 +9,7 @@ function getDatabases(dataSource, refresh = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return DatabricksDataSource.getDatabases(dataSource, refresh).catch(() => {
|
return DatabricksDataSource.getDatabases(dataSource, refresh).catch(() => {
|
||||||
notification.error("Failed to load Database list", "Please try again later.");
|
notification.error("Failed to load Database list.", "Please try again later.");
|
||||||
return Promise.reject();
|
return Promise.reject();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -20,11 +20,26 @@ function getSchema(dataSource, databaseName, refresh = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return DatabricksDataSource.getDatabaseTables(dataSource, databaseName, refresh).catch(() => {
|
return DatabricksDataSource.getDatabaseTables(dataSource, databaseName, refresh).catch(() => {
|
||||||
notification.error("Failed to load Schema", "Please try again later.");
|
notification.error(`Failed to load tables for ${databaseName}.`, "Please try again later.");
|
||||||
return Promise.reject();
|
return Promise.reject();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addDisplayNameWithoutDatabaseName(schema, databaseName) {
|
||||||
|
if (!databaseName) {
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
// add display name without {databaseName} + "."
|
||||||
|
return map(schema, table => {
|
||||||
|
const databaseNamePrefix = databaseName + ".";
|
||||||
|
let displayName = table.name;
|
||||||
|
if (startsWith(table.name, databaseNamePrefix)) {
|
||||||
|
displayName = table.name.slice(databaseNamePrefix.length);
|
||||||
|
}
|
||||||
|
return { ...table, displayName };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function useDatabricksSchema(dataSource, options = null, onOptionsUpdate = null) {
|
export default function useDatabricksSchema(dataSource, options = null, onOptionsUpdate = null) {
|
||||||
const [databases, setDatabases] = useState([]);
|
const [databases, setDatabases] = useState([]);
|
||||||
const [loadingDatabases, setLoadingDatabases] = useState(true);
|
const [loadingDatabases, setLoadingDatabases] = useState(true);
|
||||||
@@ -72,7 +87,10 @@ export default function useDatabricksSchema(dataSource, options = null, onOption
|
|||||||
[dataSource, currentDatabaseName]
|
[dataSource, currentDatabaseName]
|
||||||
);
|
);
|
||||||
|
|
||||||
const schema = useMemo(() => get(schemas, currentDatabaseName, []), [schemas, currentDatabaseName]);
|
const schema = useMemo(() => {
|
||||||
|
const currentSchema = get(schemas, currentDatabaseName, []);
|
||||||
|
return addDisplayNameWithoutDatabaseName(currentSchema, currentDatabaseName);
|
||||||
|
}, [schemas, currentDatabaseName]);
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
const refreshAll = useCallback(() => {
|
||||||
if (!refreshing) {
|
if (!refreshing) {
|
||||||
@@ -132,12 +150,20 @@ export default function useDatabricksSchema(dataSource, options = null, onOption
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (!isCancelled) {
|
if (!isCancelled) {
|
||||||
setDatabases(data);
|
setDatabases(data);
|
||||||
setCurrentDatabaseName(
|
|
||||||
defaultDatabaseNameRef.current ||
|
// We set the database using this order:
|
||||||
localStorage.getItem(`lastSelectedDatabricksDatabase_${dataSource.id}`) ||
|
// 1. Currently selected value.
|
||||||
first(data) ||
|
// 2. Last used stored in localStorage.
|
||||||
null
|
// 3. default database.
|
||||||
);
|
// 4. first database in the list.
|
||||||
|
let lastUsedDatabase =
|
||||||
|
defaultDatabaseNameRef.current || localStorage.getItem(`lastSelectedDatabricksDatabase_${dataSource.id}`);
|
||||||
|
|
||||||
|
if (!lastUsedDatabase) {
|
||||||
|
lastUsedDatabase = includes(data, "default") ? "default" : first(data) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentDatabaseName(lastUsedDatabase);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -3,13 +3,28 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<base href="/" />
|
<base href="<%= htmlWebpackPlugin.options.baseHref %>" />
|
||||||
<title>Redash</title>
|
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||||
<script src="/static/unsupportedRedirect.js" async></script>
|
<script src="<%= htmlWebpackPlugin.options.staticPath %>unsupportedRedirect.js" async></script>
|
||||||
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png" />
|
<link
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicon-96x96.png" />
|
rel="icon"
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png" />
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href="<%= htmlWebpackPlugin.options.staticPath %>images/favicon-32x32.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="<%= htmlWebpackPlugin.options.staticPath %>images/favicon-96x96.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="16x16"
|
||||||
|
href="<%= htmlWebpackPlugin.options.staticPath %>images/favicon-16x16.png"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
20
client/app/lib/calculateTextWidth.ts
Normal file
20
client/app/lib/calculateTextWidth.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.style.display = "none";
|
||||||
|
document.body.appendChild(canvas);
|
||||||
|
|
||||||
|
export function calculateTextWidth(text: string, container = document.body) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (ctx) {
|
||||||
|
const containerStyle = window.getComputedStyle(container);
|
||||||
|
ctx.font = `${containerStyle.fontSize} ${containerStyle.fontFamily}`;
|
||||||
|
const textMetrics = ctx.measureText(text);
|
||||||
|
let actualWidth = textMetrics.width;
|
||||||
|
if ("actualBoundingBoxLeft" in textMetrics) {
|
||||||
|
// only available on evergreen browsers
|
||||||
|
actualWidth = Math.abs(textMetrics.actualBoundingBoxLeft) + Math.abs(textMetrics.actualBoundingBoxRight);
|
||||||
|
}
|
||||||
|
return actualWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
56
client/app/lib/queryFormat.test.js
Normal file
56
client/app/lib/queryFormat.test.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Query } from "@/services/query";
|
||||||
|
import * as queryFormat from "./queryFormat";
|
||||||
|
|
||||||
|
describe("QueryFormat.formatQuery", () => {
|
||||||
|
test("returns same query text when syntax is not supported", () => {
|
||||||
|
const unsupportedSyntax = "unsupported-syntax";
|
||||||
|
const queryText = "select * from example";
|
||||||
|
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(unsupportedSyntax);
|
||||||
|
const formattedQuery = queryFormat.formatQuery(queryText, unsupportedSyntax);
|
||||||
|
|
||||||
|
expect(isFormatQueryAvailable).toBeFalsy();
|
||||||
|
expect(formattedQuery).toBe(queryText);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sql", () => {
|
||||||
|
const syntax = "sql";
|
||||||
|
|
||||||
|
test("returns the formatted query text", () => {
|
||||||
|
const queryText = "select column1, column2 from example where column1 = 2";
|
||||||
|
const expectedFormattedQueryText = [
|
||||||
|
"select",
|
||||||
|
" column1,",
|
||||||
|
" column2",
|
||||||
|
"from",
|
||||||
|
" example",
|
||||||
|
"where",
|
||||||
|
" column1 = 2",
|
||||||
|
].join("\n");
|
||||||
|
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(syntax);
|
||||||
|
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
|
||||||
|
expect(isFormatQueryAvailable).toBeTruthy();
|
||||||
|
expect(formattedQueryText).toBe(expectedFormattedQueryText);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still recognizes parameters after formatting", () => {
|
||||||
|
const queryText = "select {{param1}}, {{ param2 }}, {{ date-range.start }} from example";
|
||||||
|
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
|
||||||
|
const queryParameters = new Query({ query: queryText }).getParameters().parseQuery();
|
||||||
|
const formattedQueryParameters = new Query({ query: formattedQueryText }).getParameters().parseQuery();
|
||||||
|
expect(formattedQueryParameters.sort()).toEqual(queryParameters.sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("json", () => {
|
||||||
|
const syntax = "json";
|
||||||
|
|
||||||
|
test("returns the formatted query text", () => {
|
||||||
|
const queryText = '{"collection": "example","limit": 10}';
|
||||||
|
const expectedFormattedQueryText = '{\n "collection": "example",\n "limit": 10\n}';
|
||||||
|
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(syntax);
|
||||||
|
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
|
||||||
|
expect(isFormatQueryAvailable).toBeTruthy();
|
||||||
|
expect(formattedQueryText).toBe(expectedFormattedQueryText);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
23
client/app/lib/queryFormat.ts
Normal file
23
client/app/lib/queryFormat.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { trim } from "lodash";
|
||||||
|
import sqlFormatter from "sql-formatter";
|
||||||
|
|
||||||
|
interface QueryFormatterMap {
|
||||||
|
[syntax: string]: (queryText: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueryFormatters: QueryFormatterMap = {
|
||||||
|
sql: queryText => sqlFormatter.format(trim(queryText)),
|
||||||
|
json: queryText => JSON.stringify(JSON.parse(queryText), null, 4),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isFormatQueryAvailable(syntax: string) {
|
||||||
|
return syntax in QueryFormatters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatQuery(queryText: string, syntax: string) {
|
||||||
|
if (!isFormatQueryAvailable(syntax)) {
|
||||||
|
return queryText;
|
||||||
|
}
|
||||||
|
const formatter = QueryFormatters[syntax];
|
||||||
|
return formatter(queryText);
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export const AbbreviatedTimeUnits = {
|
|||||||
MILLISECONDS: "ms",
|
MILLISECONDS: "ms",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatDateTime(value) {
|
function formatDateTimeValue(value, format) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -30,20 +30,19 @@ export function formatDateTime(value) {
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed.format(clientConfig.dateTimeFormat);
|
return parsed.format(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value) {
|
||||||
|
return formatDateTimeValue(value, clientConfig.dateTimeFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTimePrecise(value, withMilliseconds = false) {
|
||||||
|
return formatDateTimeValue(value, clientConfig.dateFormat + (withMilliseconds ? " HH:mm:ss.SSS" : " HH:mm:ss"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(value) {
|
export function formatDate(value) {
|
||||||
if (!value) {
|
return formatDateTimeValue(value, clientConfig.dateFormat);
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = moment(value);
|
|
||||||
if (!parsed.isValid()) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed.format(clientConfig.dateFormat);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function localizeTime(time) {
|
export function localizeTime(time) {
|
||||||
@@ -128,21 +127,59 @@ export function remove(items, item) {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
const units = ["bytes", "KB", "MB", "GB", "TB", "PB"];
|
/**
|
||||||
|
* Formats number to string
|
||||||
|
* @param value {number}
|
||||||
|
* @param [fractionDigits] {number}
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
export function formatNumber(value, fractionDigits = 3) {
|
||||||
|
return Math.round(value) !== value ? value.toFixed(fractionDigits) : value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export function prettySize(bytes) {
|
/**
|
||||||
if (isNaN(parseFloat(bytes)) || !isFinite(bytes)) {
|
* Formats any number using predefined units
|
||||||
return "?";
|
* @param value {string|number}
|
||||||
|
* @param divisor {number}
|
||||||
|
* @param [units] {Array<string>}
|
||||||
|
* @param [fractionDigits] {number}
|
||||||
|
* @return {{unit: string, value: string, divisor: number}}
|
||||||
|
*/
|
||||||
|
export function prettyNumberWithUnit(value, divisor, units = [], fractionDigits) {
|
||||||
|
if (isNaN(parseFloat(value)) || !isFinite(value)) {
|
||||||
|
return {
|
||||||
|
value: "",
|
||||||
|
unit: "",
|
||||||
|
divisor: 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let unit = 0;
|
let unit = 0;
|
||||||
|
let greatestDivisor = 1;
|
||||||
|
|
||||||
while (bytes >= 1024) {
|
while (value >= divisor && unit < units.length - 1) {
|
||||||
bytes /= 1024;
|
value /= divisor;
|
||||||
|
greatestDivisor *= divisor;
|
||||||
unit += 1;
|
unit += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return bytes.toFixed(3) + " " + units[unit];
|
return {
|
||||||
|
value: formatNumber(value, fractionDigits),
|
||||||
|
unit: units[unit],
|
||||||
|
divisor: greatestDivisor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prettySizeWithUnit(bytes, fractionDigits) {
|
||||||
|
return prettyNumberWithUnit(bytes, 1024, ["bytes", "KB", "MB", "GB", "TB", "PB"], fractionDigits);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prettySize(bytes) {
|
||||||
|
const { value, unit } = prettySizeWithUnit(bytes);
|
||||||
|
if (!value) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
return value + " " + unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function join(arr) {
|
export function join(arr) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<base href="{{base_href}}" />
|
<base href="{{base_href}}" />
|
||||||
<title>Redash</title>
|
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||||
<script src="/static/unsupportedRedirect.js" async></script>
|
<script src="/static/unsupportedRedirect.js" async></script>
|
||||||
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from "react";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { Alert as AlertType } from "@/components/proptypes";
|
import { Alert as AlertType } from "@/components/proptypes";
|
||||||
|
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
@@ -52,6 +53,7 @@ export default class AlertEdit extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title name={name} alert={alert} onChange={onNameChange} editMode>
|
<Title name={name} alert={alert} onChange={onNameChange} editMode>
|
||||||
|
<DynamicComponent name="AlertEdit.HeaderExtra" alert={alert} />
|
||||||
<Button className="m-r-5" onClick={() => this.cancel()}>
|
<Button className="m-r-5" onClick={() => this.cancel()}>
|
||||||
<i className="fa fa-times m-r-5" />
|
<i className="fa fa-times m-r-5" />
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import Query from "./components/Query";
|
|||||||
import AlertDestinations from "./components/AlertDestinations";
|
import AlertDestinations from "./components/AlertDestinations";
|
||||||
import HorizontalFormItem from "./components/HorizontalFormItem";
|
import HorizontalFormItem from "./components/HorizontalFormItem";
|
||||||
import { STATE_CLASS } from "../alerts/AlertsList";
|
import { STATE_CLASS } from "../alerts/AlertsList";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
|
||||||
function AlertState({ state, lastTriggered }) {
|
function AlertState({ state, lastTriggered }) {
|
||||||
return (
|
return (
|
||||||
@@ -66,6 +67,7 @@ export default class AlertView extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title name={name} alert={alert}>
|
<Title name={name} alert={alert}>
|
||||||
|
<DynamicComponent name="AlertView.HeaderExtra" alert={alert} />
|
||||||
<Tooltip title={canEdit ? "" : "You do not have sufficient permissions to edit this alert"}>
|
<Tooltip title={canEdit ? "" : "You do not have sufficient permissions to edit this alert"}>
|
||||||
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
|
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
|
||||||
<i className="fa fa-edit m-r-5" />
|
<i className="fa fa-edit m-r-5" />
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSess
|
|||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
import Paginator from "@/components/Paginator";
|
import Paginator from "@/components/Paginator";
|
||||||
import EmptyState from "@/components/empty-state/EmptyState";
|
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
|
||||||
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
|
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
|
||||||
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
|
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
|
||||||
import { StateStorage } from "@/components/items-list/classes/StateStorage";
|
import { StateStorage } from "@/components/items-list/classes/StateStorage";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
|
||||||
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
|
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
|
||||||
|
|
||||||
@@ -85,13 +86,15 @@ class AlertsList extends React.Component {
|
|||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
{controller.isLoaded && controller.isEmpty ? (
|
{controller.isLoaded && controller.isEmpty ? (
|
||||||
|
<DynamicComponent name="AlertsList.EmptyState">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="fa fa-bell-o"
|
icon="fa fa-bell-o"
|
||||||
illustration="alert"
|
illustration="alert"
|
||||||
description="Get notified on certain events"
|
description="Get notified on certain events"
|
||||||
helpLink="https://redash.io/help/user-guide/alerts/"
|
helpMessage={<EmptyStateHelpMessage helpTriggerType="ALERTS" />}
|
||||||
showAlertStep
|
showAlertStep
|
||||||
/>
|
/>
|
||||||
|
</DynamicComponent>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-responsive bg-white tiled">
|
<div className="table-responsive bg-white tiled">
|
||||||
<ItemsTable
|
<ItemsTable
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
import Paginator from "@/components/Paginator";
|
import Paginator from "@/components/Paginator";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { DashboardTagsControl } from "@/components/tags-control/TagsControl";
|
import { DashboardTagsControl } from "@/components/tags-control/TagsControl";
|
||||||
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
|
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
|
||||||
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
|
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
|
||||||
import { UrlStateStorage } from "@/components/items-list/classes/StateStorage";
|
import { UrlStateStorage } from "@/components/items-list/classes/StateStorage";
|
||||||
import * as Sidebar from "@/components/items-list/components/Sidebar";
|
import * as Sidebar from "@/components/items-list/components/Sidebar";
|
||||||
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
|
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
|
||||||
|
import useItemsListExtraActions from "@/components/items-list/hooks/useItemsListExtraActions";
|
||||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||||
import Layout from "@/components/layouts/ContentWithSidebar";
|
import Layout from "@/components/layouts/ContentWithSidebar";
|
||||||
|
|
||||||
@@ -22,12 +25,7 @@ import DashboardListEmptyState from "./components/DashboardListEmptyState";
|
|||||||
|
|
||||||
import "./dashboard-list.css";
|
import "./dashboard-list.css";
|
||||||
|
|
||||||
class DashboardList extends React.Component {
|
const sidebarMenu = [
|
||||||
static propTypes = {
|
|
||||||
controller: ControllerType.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
sidebarMenu = [
|
|
||||||
{
|
{
|
||||||
key: "all",
|
key: "all",
|
||||||
href: "dashboards",
|
href: "dashboards",
|
||||||
@@ -41,7 +39,7 @@ class DashboardList extends React.Component {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
listColumns = [
|
const listColumns = [
|
||||||
Columns.favorites({ className: "p-r-0" }),
|
Columns.favorites({ className: "p-r-0" }),
|
||||||
Columns.custom.sortable(
|
Columns.custom.sortable(
|
||||||
(text, item) => (
|
(text, item) => (
|
||||||
@@ -71,8 +69,18 @@ class DashboardList extends React.Component {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
render() {
|
function DashboardListExtraActions(props) {
|
||||||
const { controller } = this.props;
|
return <DynamicComponent name="DashboardList.Actions" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardList({ controller }) {
|
||||||
|
const {
|
||||||
|
areExtraActionsAvailable,
|
||||||
|
listColumns: tableColumns,
|
||||||
|
Component: ExtraActionsComponent,
|
||||||
|
selectedItems,
|
||||||
|
} = useItemsListExtraActions(controller, listColumns, DashboardListExtraActions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-dashboard-list">
|
<div className="page-dashboard-list">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@@ -94,7 +102,7 @@ class DashboardList extends React.Component {
|
|||||||
value={controller.searchTerm}
|
value={controller.searchTerm}
|
||||||
onChange={controller.updateSearch}
|
onChange={controller.updateSearch}
|
||||||
/>
|
/>
|
||||||
<Sidebar.Menu items={this.sidebarMenu} selected={controller.params.currentPage} />
|
<Sidebar.Menu items={sidebarMenu} selected={controller.params.currentPage} />
|
||||||
<Sidebar.Tags url="api/dashboards/tags" onChange={controller.updateSelectedTags} showUnselectAll />
|
<Sidebar.Tags url="api/dashboards/tags" onChange={controller.updateSelectedTags} showUnselectAll />
|
||||||
</Layout.Sidebar>
|
</Layout.Sidebar>
|
||||||
<Layout.Content>
|
<Layout.Content>
|
||||||
@@ -106,11 +114,15 @@ class DashboardList extends React.Component {
|
|||||||
selectedTags={controller.selectedTags}
|
selectedTags={controller.selectedTags}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className={cx({ "m-b-10": areExtraActionsAvailable })}>
|
||||||
|
<ExtraActionsComponent selectedItems={selectedItems} />
|
||||||
|
</div>
|
||||||
<div className="bg-white tiled table-responsive">
|
<div className="bg-white tiled table-responsive">
|
||||||
<ItemsTable
|
<ItemsTable
|
||||||
items={controller.pageItems}
|
items={controller.pageItems}
|
||||||
loading={!controller.isLoaded}
|
loading={!controller.isLoaded}
|
||||||
columns={this.listColumns}
|
columns={tableColumns}
|
||||||
orderByField={controller.orderByField}
|
orderByField={controller.orderByField}
|
||||||
orderByReverse={controller.orderByReverse}
|
orderByReverse={controller.orderByReverse}
|
||||||
toggleSorting={controller.toggleSorting}
|
toggleSorting={controller.toggleSorting}
|
||||||
@@ -124,6 +136,7 @@ class DashboardList extends React.Component {
|
|||||||
onChange={page => controller.updatePagination({ page })}
|
onChange={page => controller.updatePagination({ page })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
@@ -132,7 +145,10 @@ class DashboardList extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
DashboardList.propTypes = {
|
||||||
|
controller: ControllerType.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
const DashboardListPage = itemsList(
|
const DashboardListPage = itemsList(
|
||||||
DashboardList,
|
DashboardList,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import cx from "classnames";
|
|||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Checkbox from "antd/lib/checkbox";
|
import Checkbox from "antd/lib/checkbox";
|
||||||
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import DashboardGrid from "@/components/dashboards/DashboardGrid";
|
import DashboardGrid from "@/components/dashboards/DashboardGrid";
|
||||||
import Parameters from "@/components/Parameters";
|
import Parameters from "@/components/Parameters";
|
||||||
import Filters from "@/components/Filters";
|
import Filters from "@/components/Filters";
|
||||||
@@ -112,7 +113,12 @@ function DashboardComponent(props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" ref={setPageContainer} data-test={`DashboardId${dashboard.id}Container`}>
|
<div className="container" ref={setPageContainer} data-test={`DashboardId${dashboard.id}Container`}>
|
||||||
<DashboardHeader dashboardOptions={dashboardOptions} />
|
<DashboardHeader
|
||||||
|
dashboardOptions={dashboardOptions}
|
||||||
|
headerExtra={
|
||||||
|
<DynamicComponent name="Dashboard.HeaderExtra" dashboard={dashboard} dashboardOptions={dashboardOptions} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
{!isEmpty(globalParameters) && (
|
{!isEmpty(globalParameters) && (
|
||||||
<div className="dashboard-parameters m-b-10 p-15 bg-white tiled" data-test="DashboardParameters">
|
<div className="dashboard-parameters m-b-10 p-15 bg-white tiled" data-test="DashboardParameters">
|
||||||
<Parameters parameters={globalParameters} onValuesChange={refreshDashboard} />
|
<Parameters parameters={globalParameters} onValuesChange={refreshDashboard} />
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ function DashboardMoreOptionsButton({ dashboardOptions }) {
|
|||||||
<a onClick={managePermissions}>Manage Permissions</a>
|
<a onClick={managePermissions}>Manage Permissions</a>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
{!dashboard.is_draft && (
|
{!clientConfig.disablePublish && !dashboard.is_draft && (
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<a onClick={togglePublished}>Unpublish</a>
|
<a onClick={togglePublished}>Unpublish</a>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -166,7 +166,7 @@ DashboardMoreOptionsButton.propTypes = {
|
|||||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
};
|
};
|
||||||
|
|
||||||
function DashboardControl({ dashboardOptions }) {
|
function DashboardControl({ dashboardOptions, headerExtra }) {
|
||||||
const {
|
const {
|
||||||
dashboard,
|
dashboard,
|
||||||
togglePublished,
|
togglePublished,
|
||||||
@@ -198,6 +198,7 @@ function DashboardControl({ dashboardOptions }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{headerExtra}
|
||||||
{showShareButton && (
|
{showShareButton && (
|
||||||
<Tooltip title="Dashboard Sharing Options">
|
<Tooltip title="Dashboard Sharing Options">
|
||||||
<Button
|
<Button
|
||||||
@@ -218,9 +219,10 @@ function DashboardControl({ dashboardOptions }) {
|
|||||||
|
|
||||||
DashboardControl.propTypes = {
|
DashboardControl.propTypes = {
|
||||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
|
headerExtra: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
function DashboardEditControl({ dashboardOptions }) {
|
function DashboardEditControl({ dashboardOptions, headerExtra }) {
|
||||||
const { setEditingLayout, doneBtnClickedWhileSaving, dashboardStatus, retrySaveDashboardLayout } = dashboardOptions;
|
const { setEditingLayout, doneBtnClickedWhileSaving, dashboardStatus, retrySaveDashboardLayout } = dashboardOptions;
|
||||||
let status;
|
let status;
|
||||||
if (dashboardStatus === DashboardStatusEnum.SAVED) {
|
if (dashboardStatus === DashboardStatusEnum.SAVED) {
|
||||||
@@ -250,26 +252,29 @@ function DashboardEditControl({ dashboardOptions }) {
|
|||||||
{!doneBtnClickedWhileSaving && <i className="fa fa-check m-r-5" />} Done Editing
|
{!doneBtnClickedWhileSaving && <i className="fa fa-check m-r-5" />} Done Editing
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{headerExtra}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
DashboardEditControl.propTypes = {
|
DashboardEditControl.propTypes = {
|
||||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
|
headerExtra: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DashboardHeader({ dashboardOptions }) {
|
export default function DashboardHeader({ dashboardOptions, headerExtra }) {
|
||||||
const { editingLayout } = dashboardOptions;
|
const { editingLayout } = dashboardOptions;
|
||||||
const DashboardControlComponent = editingLayout ? DashboardEditControl : DashboardControl;
|
const DashboardControlComponent = editingLayout ? DashboardEditControl : DashboardControl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-header">
|
<div className="dashboard-header">
|
||||||
<DashboardPageTitle dashboardOptions={dashboardOptions} />
|
<DashboardPageTitle dashboardOptions={dashboardOptions} />
|
||||||
<DashboardControlComponent dashboardOptions={dashboardOptions} />
|
<DashboardControlComponent dashboardOptions={dashboardOptions} headerExtra={headerExtra} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
DashboardHeader.propTypes = {
|
DashboardHeader.propTypes = {
|
||||||
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
|
headerExtra: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import * as React from "react";
|
|||||||
import * as PropTypes from "prop-types";
|
import * as PropTypes from "prop-types";
|
||||||
import BigMessage from "@/components/BigMessage";
|
import BigMessage from "@/components/BigMessage";
|
||||||
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
|
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
|
||||||
import EmptyState from "@/components/empty-state/EmptyState";
|
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
|
||||||
export interface DashboardListEmptyStateProps {
|
export interface DashboardListEmptyStateProps {
|
||||||
page: string;
|
page: string;
|
||||||
@@ -22,13 +23,15 @@ export default function DashboardListEmptyState({ page, searchTerm, selectedTags
|
|||||||
return <BigMessage message="Mark dashboards as Favorite to list them here." icon="fa-star" />;
|
return <BigMessage message="Mark dashboards as Favorite to list them here." icon="fa-star" />;
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
|
<DynamicComponent name="DashboardList.EmptyState">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="zmdi zmdi-view-quilt"
|
icon="zmdi zmdi-view-quilt"
|
||||||
description="See the big picture"
|
description="See the big picture"
|
||||||
illustration="dashboard"
|
illustration="dashboard"
|
||||||
helpLink="https://help.redash.io/category/22-dashboards"
|
helpMessage={<EmptyStateHelpMessage helpTriggerType="DASHBOARDS" />}
|
||||||
showDashboardStep
|
showDashboardStep
|
||||||
/>
|
/>
|
||||||
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import ShareDashboardDialog from "../components/ShareDashboardDialog";
|
|||||||
import useFullscreenHandler from "../../../lib/hooks/useFullscreenHandler";
|
import useFullscreenHandler from "../../../lib/hooks/useFullscreenHandler";
|
||||||
import useRefreshRateHandler from "./useRefreshRateHandler";
|
import useRefreshRateHandler from "./useRefreshRateHandler";
|
||||||
import useEditModeHandler from "./useEditModeHandler";
|
import useEditModeHandler from "./useEditModeHandler";
|
||||||
|
import { policy } from "@/services/policy";
|
||||||
|
|
||||||
export { DashboardStatusEnum } from "./useEditModeHandler";
|
export { DashboardStatusEnum } from "./useEditModeHandler";
|
||||||
|
|
||||||
@@ -39,12 +40,12 @@ function useDashboard(dashboardData) {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [gridDisabled, setGridDisabled] = useState(false);
|
const [gridDisabled, setGridDisabled] = useState(false);
|
||||||
const globalParameters = useMemo(() => dashboard.getParametersDefs(), [dashboard]);
|
const globalParameters = useMemo(() => dashboard.getParametersDefs(), [dashboard]);
|
||||||
const canEditDashboard = !dashboard.is_archived && dashboard.can_edit;
|
const canEditDashboard = !dashboard.is_archived && policy.canEdit(dashboard);
|
||||||
const isDashboardOwnerOrAdmin = useMemo(
|
const isDashboardOwnerOrAdmin = useMemo(
|
||||||
() =>
|
() =>
|
||||||
!dashboard.is_archived &&
|
!dashboard.is_archived &&
|
||||||
has(dashboard, "user.id") &&
|
has(dashboard, "user.id") &&
|
||||||
(currentUser.id === dashboard.user.id || currentUser.hasPermission("admin")),
|
(currentUser.id === dashboard.user.id || currentUser.isAdmin),
|
||||||
[dashboard]
|
[dashboard]
|
||||||
);
|
);
|
||||||
const hasOnlySafeQueries = useMemo(
|
const hasOnlySafeQueries = useMemo(
|
||||||
|
|||||||
28
client/app/pages/dashboards/hooks/useDataSources.js
Normal file
28
client/app/pages/dashboards/hooks/useDataSources.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { filter } from "lodash";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import DataSource from "@/services/data-source";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a list of all data sources, as well as a boolean to say whether they've been loaded
|
||||||
|
*/
|
||||||
|
export default function useDataSources() {
|
||||||
|
const [allDataSources, setAllDataSources] = useState([]);
|
||||||
|
const [dataSourcesLoaded, setDataSourcesLoaded] = useState(false);
|
||||||
|
const dataSources = filter(allDataSources, ds => !ds.view_only);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelDataSourceLoading = false;
|
||||||
|
DataSource.query().then(data => {
|
||||||
|
if (!cancelDataSourceLoading) {
|
||||||
|
setDataSourcesLoaded(true);
|
||||||
|
setAllDataSources(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelDataSourceLoading = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { dataSourcesLoaded, dataSources };
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { includes, isEmpty } from "lodash";
|
import { includes } from "lodash";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect } from "react";
|
||||||
import PropTypes from "prop-types";
|
|
||||||
|
|
||||||
import Alert from "antd/lib/alert";
|
import Alert from "antd/lib/alert";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
import LoadingOutlinedIcon from "@ant-design/icons/LoadingOutlined";
|
|
||||||
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
||||||
import EmptyState from "@/components/empty-state/EmptyState";
|
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import BeaconConsent from "@/components/BeaconConsent";
|
import BeaconConsent from "@/components/BeaconConsent";
|
||||||
|
|
||||||
@@ -14,10 +12,10 @@ import { axios } from "@/services/axios";
|
|||||||
import recordEvent from "@/services/recordEvent";
|
import recordEvent from "@/services/recordEvent";
|
||||||
import { messages } from "@/services/auth";
|
import { messages } from "@/services/auth";
|
||||||
import notification from "@/services/notification";
|
import notification from "@/services/notification";
|
||||||
import { Dashboard } from "@/services/dashboard";
|
|
||||||
import { Query } from "@/services/query";
|
|
||||||
import routes from "@/services/routes";
|
import routes from "@/services/routes";
|
||||||
|
|
||||||
|
import { DashboardAndQueryFavoritesList } from "./components/FavoritesList";
|
||||||
|
|
||||||
import "./Home.less";
|
import "./Home.less";
|
||||||
|
|
||||||
function DeprecatedEmbedFeatureAlert() {
|
function DeprecatedEmbedFeatureAlert() {
|
||||||
@@ -67,92 +65,7 @@ function EmailNotVerifiedAlert() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FavoriteList({ title, resource, itemUrl, emptyState }) {
|
export default function Home() {
|
||||||
const [items, setItems] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true);
|
|
||||||
resource
|
|
||||||
.favorites()
|
|
||||||
.then(({ results }) => setItems(results))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [resource]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="d-flex align-items-center m-b-20">
|
|
||||||
<p className="flex-fill f-500 c-black m-0">{title}</p>
|
|
||||||
{loading && <LoadingOutlinedIcon />}
|
|
||||||
</div>
|
|
||||||
{!isEmpty(items) && (
|
|
||||||
<div className="list-group">
|
|
||||||
{items.map(item => (
|
|
||||||
<Link key={itemUrl(item)} className="list-group-item" href={itemUrl(item)}>
|
|
||||||
<span className="btn-favourite m-r-5">
|
|
||||||
<i className="fa fa-star" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
{item.name}
|
|
||||||
{item.is_draft && <span className="label label-default m-l-5">Unpublished</span>}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isEmpty(items) && !loading && emptyState}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
FavoriteList.propTypes = {
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
resource: PropTypes.func.isRequired, // eslint-disable-line react/forbid-prop-types
|
|
||||||
itemUrl: PropTypes.func.isRequired,
|
|
||||||
emptyState: PropTypes.node,
|
|
||||||
};
|
|
||||||
FavoriteList.defaultProps = { emptyState: null };
|
|
||||||
|
|
||||||
function DashboardAndQueryFavoritesList() {
|
|
||||||
return (
|
|
||||||
<div className="tile">
|
|
||||||
<div className="t-body tb-padding">
|
|
||||||
<div className="row home-favorites-list">
|
|
||||||
<div className="col-sm-6 m-t-20">
|
|
||||||
<FavoriteList
|
|
||||||
title="Favorite Dashboards"
|
|
||||||
resource={Dashboard}
|
|
||||||
itemUrl={dashboard => dashboard.url}
|
|
||||||
emptyState={
|
|
||||||
<p>
|
|
||||||
<span className="btn-favourite m-r-5">
|
|
||||||
<i className="fa fa-star" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
Favorite <Link href="dashboards">Dashboards</Link> will appear here
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-6 m-t-20">
|
|
||||||
<FavoriteList
|
|
||||||
title="Favorite Queries"
|
|
||||||
resource={Query}
|
|
||||||
itemUrl={query => `queries/${query.id}`}
|
|
||||||
emptyState={
|
|
||||||
<p>
|
|
||||||
<span className="btn-favourite m-r-5">
|
|
||||||
<i className="fa fa-star" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
Favorite <Link href="queries">Queries</Link> will appear here
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Home() {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
recordEvent("view", "page", "personal_homepage");
|
recordEvent("view", "page", "personal_homepage");
|
||||||
}, []);
|
}, []);
|
||||||
@@ -162,15 +75,17 @@ function Home() {
|
|||||||
<div className="container">
|
<div className="container">
|
||||||
{includes(messages, "using-deprecated-embed-feature") && <DeprecatedEmbedFeatureAlert />}
|
{includes(messages, "using-deprecated-embed-feature") && <DeprecatedEmbedFeatureAlert />}
|
||||||
{includes(messages, "email-not-verified") && <EmailNotVerifiedAlert />}
|
{includes(messages, "email-not-verified") && <EmailNotVerifiedAlert />}
|
||||||
|
<DynamicComponent name="Home.EmptyState">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
header="Welcome to Redash 👋"
|
header="Welcome to Redash 👋"
|
||||||
description="Connect to any data source, easily visualize and share your data"
|
description="Connect to any data source, easily visualize and share your data"
|
||||||
illustration="dashboard"
|
illustration="dashboard"
|
||||||
helpLink="https://redash.io/help/user-guide/getting-started"
|
helpMessage={<EmptyStateHelpMessage helpTriggerType="GETTING_STARTED" />}
|
||||||
showDashboardStep
|
showDashboardStep
|
||||||
showInviteStep
|
showInviteStep
|
||||||
onboardingMode
|
onboardingMode
|
||||||
/>
|
/>
|
||||||
|
</DynamicComponent>
|
||||||
<DynamicComponent name="HomeExtra" />
|
<DynamicComponent name="HomeExtra" />
|
||||||
<DashboardAndQueryFavoritesList />
|
<DashboardAndQueryFavoritesList />
|
||||||
<BeaconConsent />
|
<BeaconConsent />
|
||||||
|
|||||||
94
client/app/pages/home/components/FavoritesList.jsx
Normal file
94
client/app/pages/home/components/FavoritesList.jsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { isEmpty } from "lodash";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
import Link from "@/components/Link";
|
||||||
|
import LoadingOutlinedIcon from "@ant-design/icons/LoadingOutlined";
|
||||||
|
|
||||||
|
import { Dashboard } from "@/services/dashboard";
|
||||||
|
import { Query } from "@/services/query";
|
||||||
|
|
||||||
|
export function FavoriteList({ title, resource, itemUrl, emptyState }) {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
resource
|
||||||
|
.favorites()
|
||||||
|
.then(({ results }) => setItems(results))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [resource]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="d-flex align-items-center m-b-20">
|
||||||
|
<p className="flex-fill f-500 c-black m-0">{title}</p>
|
||||||
|
{loading && <LoadingOutlinedIcon />}
|
||||||
|
</div>
|
||||||
|
{!isEmpty(items) && (
|
||||||
|
<div className="list-group">
|
||||||
|
{items.map(item => (
|
||||||
|
<Link key={itemUrl(item)} className="list-group-item" href={itemUrl(item)}>
|
||||||
|
<span className="btn-favourite m-r-5">
|
||||||
|
<i className="fa fa-star" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
{item.name}
|
||||||
|
{item.is_draft && <span className="label label-default m-l-5">Unpublished</span>}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isEmpty(items) && !loading && emptyState}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FavoriteList.propTypes = {
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
resource: PropTypes.func.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
|
itemUrl: PropTypes.func.isRequired,
|
||||||
|
emptyState: PropTypes.node,
|
||||||
|
};
|
||||||
|
FavoriteList.defaultProps = { emptyState: null };
|
||||||
|
|
||||||
|
export function DashboardAndQueryFavoritesList() {
|
||||||
|
return (
|
||||||
|
<div className="tile">
|
||||||
|
<div className="t-body tb-padding">
|
||||||
|
<div className="row home-favorites-list">
|
||||||
|
<div className="col-sm-6 m-t-20">
|
||||||
|
<FavoriteList
|
||||||
|
title="Favorite Dashboards"
|
||||||
|
resource={Dashboard}
|
||||||
|
itemUrl={dashboard => dashboard.url}
|
||||||
|
emptyState={
|
||||||
|
<p>
|
||||||
|
<span className="btn-favourite m-r-5">
|
||||||
|
<i className="fa fa-star" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
Favorite <Link href="dashboards">Dashboards</Link> will appear here
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 m-t-20">
|
||||||
|
<FavoriteList
|
||||||
|
title="Favorite Queries"
|
||||||
|
resource={Query}
|
||||||
|
itemUrl={query => `queries/${query.id}`}
|
||||||
|
emptyState={
|
||||||
|
<p>
|
||||||
|
<span className="btn-favourite m-r-5">
|
||||||
|
<i className="fa fa-star" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
Favorite <Link href="queries">Queries</Link> will appear here
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
import Paginator from "@/components/Paginator";
|
import Paginator from "@/components/Paginator";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
|
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
|
||||||
import SchedulePhrase from "@/components/queries/SchedulePhrase";
|
import SchedulePhrase from "@/components/queries/SchedulePhrase";
|
||||||
|
|
||||||
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
|
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
|
||||||
|
import useItemsListExtraActions from "@/components/items-list/hooks/useItemsListExtraActions";
|
||||||
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
|
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
|
||||||
import { UrlStateStorage } from "@/components/items-list/classes/StateStorage";
|
import { UrlStateStorage } from "@/components/items-list/classes/StateStorage";
|
||||||
|
|
||||||
@@ -25,12 +28,7 @@ import QueriesListEmptyState from "./QueriesListEmptyState";
|
|||||||
|
|
||||||
import "./queries-list.css";
|
import "./queries-list.css";
|
||||||
|
|
||||||
class QueriesList extends React.Component {
|
const sidebarMenu = [
|
||||||
static propTypes = {
|
|
||||||
controller: ControllerType.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
sidebarMenu = [
|
|
||||||
{
|
{
|
||||||
key: "all",
|
key: "all",
|
||||||
href: "queries",
|
href: "queries",
|
||||||
@@ -57,7 +55,7 @@ class QueriesList extends React.Component {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
listColumns = [
|
const listColumns = [
|
||||||
Columns.favorites({ className: "p-r-0" }),
|
Columns.favorites({ className: "p-r-0" }),
|
||||||
Columns.custom.sortable(
|
Columns.custom.sortable(
|
||||||
(text, item) => (
|
(text, item) => (
|
||||||
@@ -65,12 +63,7 @@ class QueriesList extends React.Component {
|
|||||||
<Link className="table-main-title" href={"queries/" + item.id}>
|
<Link className="table-main-title" href={"queries/" + item.id}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
<QueryTagsControl
|
<QueryTagsControl className="d-block" tags={item.tags} isDraft={item.is_draft} isArchived={item.is_archived} />
|
||||||
className="d-block"
|
|
||||||
tags={item.tags}
|
|
||||||
isDraft={item.is_draft}
|
|
||||||
isArchived={item.is_archived}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
@@ -94,24 +87,34 @@ class QueriesList extends React.Component {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
componentDidMount() {
|
function QueriesListExtraActions(props) {
|
||||||
this.unlistenLocationChanges = location.listen((unused, action) => {
|
return <DynamicComponent name="QueriesList.Actions" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueriesList({ controller }) {
|
||||||
|
const controllerRef = useRef();
|
||||||
|
controllerRef.current = controller;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlistenLocationChanges = location.listen((unused, action) => {
|
||||||
const searchTerm = location.search.q || "";
|
const searchTerm = location.search.q || "";
|
||||||
if (action === "PUSH" && searchTerm !== this.props.controller.searchTerm) {
|
if (action === "PUSH" && searchTerm !== controllerRef.current.searchTerm) {
|
||||||
this.props.controller.updateSearch(searchTerm);
|
controllerRef.current.updateSearch(searchTerm);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
return () => {
|
||||||
if (this.unlistenLocationChanges) {
|
unlistenLocationChanges();
|
||||||
this.unlistenLocationChanges();
|
};
|
||||||
this.unlistenLocationChanges = null;
|
}, []);
|
||||||
}
|
|
||||||
}
|
const {
|
||||||
|
areExtraActionsAvailable,
|
||||||
|
listColumns: tableColumns,
|
||||||
|
Component: ExtraActionsComponent,
|
||||||
|
selectedItems,
|
||||||
|
} = useItemsListExtraActions(controller, listColumns, QueriesListExtraActions);
|
||||||
|
|
||||||
render() {
|
|
||||||
const { controller } = this.props;
|
|
||||||
return (
|
return (
|
||||||
<div className="page-queries-list">
|
<div className="page-queries-list">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@@ -133,7 +136,7 @@ class QueriesList extends React.Component {
|
|||||||
value={controller.searchTerm}
|
value={controller.searchTerm}
|
||||||
onChange={controller.updateSearch}
|
onChange={controller.updateSearch}
|
||||||
/>
|
/>
|
||||||
<Sidebar.Menu items={this.sidebarMenu} selected={controller.params.currentPage} />
|
<Sidebar.Menu items={sidebarMenu} selected={controller.params.currentPage} />
|
||||||
<Sidebar.Tags url="api/queries/tags" onChange={controller.updateSelectedTags} showUnselectAll />
|
<Sidebar.Tags url="api/queries/tags" onChange={controller.updateSelectedTags} showUnselectAll />
|
||||||
</Layout.Sidebar>
|
</Layout.Sidebar>
|
||||||
<Layout.Content>
|
<Layout.Content>
|
||||||
@@ -144,11 +147,15 @@ class QueriesList extends React.Component {
|
|||||||
selectedTags={controller.selectedTags}
|
selectedTags={controller.selectedTags}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className={cx({ "m-b-10": areExtraActionsAvailable })}>
|
||||||
|
<ExtraActionsComponent selectedItems={selectedItems} />
|
||||||
|
</div>
|
||||||
<div className="bg-white tiled table-responsive">
|
<div className="bg-white tiled table-responsive">
|
||||||
<ItemsTable
|
<ItemsTable
|
||||||
items={controller.pageItems}
|
items={controller.pageItems}
|
||||||
loading={!controller.isLoaded}
|
loading={!controller.isLoaded}
|
||||||
columns={this.listColumns}
|
columns={tableColumns}
|
||||||
orderByField={controller.orderByField}
|
orderByField={controller.orderByField}
|
||||||
orderByReverse={controller.orderByReverse}
|
orderByReverse={controller.orderByReverse}
|
||||||
toggleSorting={controller.toggleSorting}
|
toggleSorting={controller.toggleSorting}
|
||||||
@@ -162,6 +169,7 @@ class QueriesList extends React.Component {
|
|||||||
onChange={page => controller.updatePagination({ page })}
|
onChange={page => controller.updatePagination({ page })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -169,7 +177,10 @@ class QueriesList extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
QueriesList.propTypes = {
|
||||||
|
controller: ControllerType.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
const QueriesListPage = itemsList(
|
const QueriesListPage = itemsList(
|
||||||
QueriesList,
|
QueriesList,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import PropTypes from "prop-types";
|
|||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
import BigMessage from "@/components/BigMessage";
|
import BigMessage from "@/components/BigMessage";
|
||||||
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
|
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
|
||||||
import EmptyState from "@/components/empty-state/EmptyState";
|
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
|
||||||
export default function QueriesListEmptyState({ page, searchTerm, selectedTags }) {
|
export default function QueriesListEmptyState({ page, searchTerm, selectedTags }) {
|
||||||
if (searchTerm !== "") {
|
if (searchTerm !== "") {
|
||||||
@@ -29,12 +30,14 @@ export default function QueriesListEmptyState({ page, searchTerm, selectedTags }
|
|||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
|
<DynamicComponent name="QueriesList.EmptyState">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="fa fa-code"
|
icon="fa fa-code"
|
||||||
illustration="query"
|
illustration="query"
|
||||||
description="Getting the data from your datasources."
|
description="Getting the data from your datasources."
|
||||||
helpLink="https://help.redash.io/category/21-querying"
|
helpMessage={<EmptyStateHelpMessage helpTriggerType="QUERIES" />}
|
||||||
/>
|
/>
|
||||||
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { isEmpty, find, map, extend, includes } from "lodash";
|
import { extend, find, includes, isEmpty, map } from "lodash";
|
||||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import useMedia from "use-media";
|
import useMedia from "use-media";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Select from "antd/lib/select";
|
|
||||||
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
||||||
import Resizable from "@/components/Resizable";
|
import Resizable from "@/components/Resizable";
|
||||||
import Parameters from "@/components/Parameters";
|
import Parameters from "@/components/Parameters";
|
||||||
import EditInPlace from "@/components/EditInPlace";
|
import EditInPlace from "@/components/EditInPlace";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import recordEvent from "@/services/recordEvent";
|
import recordEvent from "@/services/recordEvent";
|
||||||
import { ExecutionStatus } from "@/services/query-result";
|
import { ExecutionStatus } from "@/services/query-result";
|
||||||
import routes from "@/services/routes";
|
import routes from "@/services/routes";
|
||||||
|
import notification from "@/services/notification";
|
||||||
|
import * as queryFormat from "@/lib/queryFormat";
|
||||||
|
|
||||||
import QueryPageHeader from "./components/QueryPageHeader";
|
import QueryPageHeader from "./components/QueryPageHeader";
|
||||||
import QueryMetadata from "./components/QueryMetadata";
|
import QueryMetadata from "./components/QueryMetadata";
|
||||||
@@ -37,11 +39,11 @@ import useEditScheduleDialog from "./hooks/useEditScheduleDialog";
|
|||||||
import useAddVisualizationDialog from "./hooks/useAddVisualizationDialog";
|
import useAddVisualizationDialog from "./hooks/useAddVisualizationDialog";
|
||||||
import useEditVisualizationDialog from "./hooks/useEditVisualizationDialog";
|
import useEditVisualizationDialog from "./hooks/useEditVisualizationDialog";
|
||||||
import useDeleteVisualization from "./hooks/useDeleteVisualization";
|
import useDeleteVisualization from "./hooks/useDeleteVisualization";
|
||||||
import useFormatQuery from "./hooks/useFormatQuery";
|
|
||||||
import useUpdateQuery from "./hooks/useUpdateQuery";
|
import useUpdateQuery from "./hooks/useUpdateQuery";
|
||||||
import useUpdateQueryDescription from "./hooks/useUpdateQueryDescription";
|
import useUpdateQueryDescription from "./hooks/useUpdateQueryDescription";
|
||||||
import useUnsavedChangesAlert from "./hooks/useUnsavedChangesAlert";
|
import useUnsavedChangesAlert from "./hooks/useUnsavedChangesAlert";
|
||||||
|
|
||||||
|
import "./components/QuerySourceDropdown"; // register QuerySourceDropdown
|
||||||
import "./QuerySource.less";
|
import "./QuerySource.less";
|
||||||
|
|
||||||
function chooseDataSourceId(dataSourceIds, availableDataSources) {
|
function chooseDataSourceId(dataSourceIds, availableDataSources) {
|
||||||
@@ -94,7 +96,16 @@ function QuerySource(props) {
|
|||||||
|
|
||||||
const updateQuery = useUpdateQuery(query, setQuery);
|
const updateQuery = useUpdateQuery(query, setQuery);
|
||||||
const updateQueryDescription = useUpdateQueryDescription(query, setQuery);
|
const updateQueryDescription = useUpdateQueryDescription(query, setQuery);
|
||||||
const formatQuery = useFormatQuery(query, dataSource ? dataSource.syntax : null, setQuery);
|
const querySyntax = dataSource ? dataSource.syntax || "sql" : null;
|
||||||
|
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(querySyntax);
|
||||||
|
const formatQuery = () => {
|
||||||
|
try {
|
||||||
|
const formattedQueryText = queryFormat.formatQuery(query.query, querySyntax);
|
||||||
|
setQuery(extend(query.clone(), { query: formattedQueryText }));
|
||||||
|
} catch (err) {
|
||||||
|
notification.error(String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDataSourceChange = useCallback(
|
const handleDataSourceChange = useCallback(
|
||||||
dataSourceId => {
|
dataSourceId => {
|
||||||
@@ -190,6 +201,7 @@ function QuerySource(props) {
|
|||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
sourceMode
|
sourceMode
|
||||||
selectedVisualization={selectedVisualization}
|
selectedVisualization={selectedVisualization}
|
||||||
|
headerExtra={<DynamicComponent name="QuerySource.HeaderExtra" query={query} />}
|
||||||
onChange={setQuery}
|
onChange={setQuery}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,27 +210,14 @@ function QuerySource(props) {
|
|||||||
<nav>
|
<nav>
|
||||||
{dataSourcesLoaded && (
|
{dataSourcesLoaded && (
|
||||||
<div className="editor__left__data-source">
|
<div className="editor__left__data-source">
|
||||||
<Select
|
<DynamicComponent
|
||||||
className="w-100"
|
name={"QuerySourceDropdown"}
|
||||||
data-test="SelectDataSource"
|
dataSources={dataSources}
|
||||||
placeholder="Choose data source..."
|
|
||||||
value={dataSource ? dataSource.id : undefined}
|
value={dataSource ? dataSource.id : undefined}
|
||||||
disabled={!queryFlags.canEdit || !dataSourcesLoaded || dataSources.length === 0}
|
disabled={!queryFlags.canEdit || !dataSourcesLoaded || dataSources.length === 0}
|
||||||
loading={!dataSourcesLoaded}
|
loading={!dataSourcesLoaded}
|
||||||
optionFilterProp="data-name"
|
onChange={handleDataSourceChange}
|
||||||
showSearch
|
/>
|
||||||
onChange={handleDataSourceChange}>
|
|
||||||
{map(dataSources, ds => (
|
|
||||||
<Select.Option
|
|
||||||
key={`ds-${ds.id}`}
|
|
||||||
value={ds.id}
|
|
||||||
data-name={ds.name}
|
|
||||||
data-test={`SelectDataSource${ds.id}`}>
|
|
||||||
<img src={`static/images/db-logos/${ds.type}.png`} width="20" alt={ds.name} />
|
|
||||||
<span>{ds.name}</span>
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="editor__left__schema">
|
<div className="editor__left__schema">
|
||||||
@@ -277,8 +276,11 @@ function QuerySource(props) {
|
|||||||
onClick: openAddNewParameterDialog,
|
onClick: openAddNewParameterDialog,
|
||||||
}}
|
}}
|
||||||
formatButtonProps={{
|
formatButtonProps={{
|
||||||
title: "Format Query",
|
title: isFormatQueryAvailable
|
||||||
shortcut: "mod+shift+f",
|
? "Format Query"
|
||||||
|
: "Query formatting is not supported for your Data Source syntax",
|
||||||
|
disabled: !dataSource || !isFormatQueryAvailable,
|
||||||
|
shortcut: isFormatQueryAvailable ? "mod+shift+f" : null,
|
||||||
onClick: formatQuery,
|
onClick: formatQuery,
|
||||||
}}
|
}}
|
||||||
saveButtonProps={
|
saveButtonProps={
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import FullscreenExitOutlinedIcon from "@ant-design/icons/FullscreenExitOutlined
|
|||||||
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
||||||
import EditInPlace from "@/components/EditInPlace";
|
import EditInPlace from "@/components/EditInPlace";
|
||||||
import Parameters from "@/components/Parameters";
|
import Parameters from "@/components/Parameters";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
|
||||||
import DataSource from "@/services/data-source";
|
import DataSource from "@/services/data-source";
|
||||||
import { ExecutionStatus } from "@/services/query-result";
|
import { ExecutionStatus } from "@/services/query-result";
|
||||||
import routes from "@/services/routes";
|
import routes from "@/services/routes";
|
||||||
|
import { policy } from "@/services/policy";
|
||||||
|
|
||||||
import useQueryResultData from "@/lib/useQueryResultData";
|
import useQueryResultData from "@/lib/useQueryResultData";
|
||||||
|
|
||||||
@@ -102,6 +104,8 @@ function QueryView(props) {
|
|||||||
onChange={setQuery}
|
onChange={setQuery}
|
||||||
selectedVisualization={selectedVisualization}
|
selectedVisualization={selectedVisualization}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
|
<DynamicComponent name="QueryView.HeaderExtra" query={query}>
|
||||||
|
{policy.canRun(query) && (
|
||||||
<QueryViewButton
|
<QueryViewButton
|
||||||
className="m-r-5"
|
className="m-r-5"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -110,6 +114,8 @@ function QueryView(props) {
|
|||||||
onClick={doExecuteQuery}>
|
onClick={doExecuteQuery}>
|
||||||
Refresh
|
Refresh
|
||||||
</QueryViewButton>
|
</QueryViewButton>
|
||||||
|
)}
|
||||||
|
</DynamicComponent>
|
||||||
}
|
}
|
||||||
tagsExtra={
|
tagsExtra={
|
||||||
!query.description &&
|
!query.description &&
|
||||||
@@ -165,6 +171,7 @@ function QueryView(props) {
|
|||||||
onAddVisualization={addVisualization}
|
onAddVisualization={addVisualization}
|
||||||
onDeleteVisualization={deleteVisualization}
|
onDeleteVisualization={deleteVisualization}
|
||||||
refreshButton={
|
refreshButton={
|
||||||
|
policy.canRun(query) && (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
disabled={!queryFlags.canExecute || areParametersDirty}
|
disabled={!queryFlags.canExecute || areParametersDirty}
|
||||||
@@ -173,7 +180,9 @@ function QueryView(props) {
|
|||||||
{!isExecuting && <i className="zmdi zmdi-refresh m-r-5" aria-hidden="true" />}
|
{!isExecuting && <i className="zmdi zmdi-refresh m-r-5" aria-hidden="true" />}
|
||||||
Refresh Now
|
Refresh Now
|
||||||
</Button>
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
canRefresh={policy.canRun(query)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="query-results-footer">
|
<div className="query-results-footer">
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export default function QueryPageHeader({
|
|||||||
onClick: publishQuery,
|
onClick: publishQuery,
|
||||||
},
|
},
|
||||||
unpublish: {
|
unpublish: {
|
||||||
isAvailable: !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isDraft,
|
isAvailable: !clientConfig.disablePublish && !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isDraft,
|
||||||
title: "Unpublish",
|
title: "Unpublish",
|
||||||
onClick: unpublishQuery,
|
onClick: unpublishQuery,
|
||||||
},
|
},
|
||||||
@@ -179,7 +179,7 @@ export default function QueryPageHeader({
|
|||||||
|
|
||||||
{!queryFlags.isNew && queryFlags.canViewSource && (
|
{!queryFlags.isNew && queryFlags.canViewSource && (
|
||||||
<span>
|
<span>
|
||||||
{!sourceMode && (
|
{!sourceMode && queryFlags.canEdit && (
|
||||||
<Link.Button className="m-r-5" href={query.getUrl(true, selectedVisualization)}>
|
<Link.Button className="m-r-5" href={query.getUrl(true, selectedVisualization)}>
|
||||||
<i className="fa fa-pencil-square-o" aria-hidden="true" />
|
<i className="fa fa-pencil-square-o" aria-hidden="true" />
|
||||||
<span className="m-l-5">Edit Source</span>
|
<span className="m-l-5">Edit Source</span>
|
||||||
@@ -189,9 +189,9 @@ export default function QueryPageHeader({
|
|||||||
<Link.Button
|
<Link.Button
|
||||||
className="m-r-5"
|
className="m-r-5"
|
||||||
href={query.getUrl(false, selectedVisualization)}
|
href={query.getUrl(false, selectedVisualization)}
|
||||||
data-test="QueryPageShowDataOnly">
|
data-test="QueryPageShowResultOnly">
|
||||||
<i className="fa fa-table" aria-hidden="true" />
|
<i className="fa fa-table" aria-hidden="true" />
|
||||||
<span className="m-l-5">Show Data Only</span>
|
<span className="m-l-5">Show Results Only</span>
|
||||||
</Link.Button>
|
</Link.Button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -211,7 +211,7 @@ export default function QueryPageHeader({
|
|||||||
|
|
||||||
QueryPageHeader.propTypes = {
|
QueryPageHeader.propTypes = {
|
||||||
query: PropTypes.shape({
|
query: PropTypes.shape({
|
||||||
id: PropTypes.number,
|
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
tags: PropTypes.arrayOf(PropTypes.string),
|
tags: PropTypes.arrayOf(PropTypes.string),
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Card from "antd/lib/card";
|
|||||||
import WarningFilledIcon from "@ant-design/icons/WarningFilled";
|
import WarningFilledIcon from "@ant-design/icons/WarningFilled";
|
||||||
import Typography from "antd/lib/typography";
|
import Typography from "antd/lib/typography";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { currentUser } from "@/services/auth";
|
import { currentUser } from "@/services/auth";
|
||||||
|
|
||||||
import useQueryFlags from "../hooks/useQueryFlags";
|
import useQueryFlags from "../hooks/useQueryFlags";
|
||||||
@@ -69,10 +70,12 @@ export default function QuerySourceAlerts({ query, dataSourcesAvailable }) {
|
|||||||
return (
|
return (
|
||||||
<div className="query-source-alerts">
|
<div className="query-source-alerts">
|
||||||
<Card>
|
<Card>
|
||||||
|
<DynamicComponent name="QuerySource.Alerts" query={query} dataSourcesAvailable={dataSourcesAvailable}>
|
||||||
<div className="query-source-alerts-icon">
|
<div className="query-source-alerts-icon">
|
||||||
<WarningFilledIcon />
|
<WarningFilledIcon />
|
||||||
</div>
|
</div>
|
||||||
{message}
|
{message}
|
||||||
|
</DynamicComponent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
38
client/app/pages/queries/components/QuerySourceDropdown.jsx
Normal file
38
client/app/pages/queries/components/QuerySourceDropdown.jsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Select from "antd/lib/select";
|
||||||
|
import { map } from "lodash";
|
||||||
|
import DynamicComponent, { registerComponent } from "@/components/DynamicComponent";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import "./QuerySourceDropdownItem"; // register QuerySourceDropdownItem
|
||||||
|
|
||||||
|
export function QuerySourceDropdown(props) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
className="w-100"
|
||||||
|
data-test="SelectDataSource"
|
||||||
|
placeholder="Choose data source..."
|
||||||
|
value={props.value}
|
||||||
|
disabled={props.disabled}
|
||||||
|
loading={props.loading}
|
||||||
|
optionFilterProp="data-name"
|
||||||
|
showSearch
|
||||||
|
onChange={props.onChange}>
|
||||||
|
{map(props.dataSources, ds => (
|
||||||
|
<Select.Option key={`ds-${ds.id}`} value={ds.id} data-name={ds.name} data-test={`SelectDataSource${ds.id}`}>
|
||||||
|
<DynamicComponent name={"QuerySourceDropdownItem"} dataSource={ds} />
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
QuerySourceDropdown.propTypes = {
|
||||||
|
dataSources: PropTypes.any,
|
||||||
|
value: PropTypes.string,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
loading: PropTypes.bool,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
registerComponent("QuerySourceDropdown", QuerySourceDropdown);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
import React from "react";
|
||||||
|
import { registerComponent } from "@/components/DynamicComponent";
|
||||||
|
import { QuerySourceTypeIcon } from "@/pages/queries/components/QuerySourceTypeIcon";
|
||||||
|
|
||||||
|
export function QuerySourceDropdownItem({ dataSource, children }) {
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<QuerySourceTypeIcon type={dataSource.type} alt={dataSource.name} />
|
||||||
|
{children ? children : <span>{dataSource.name}</span>}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
QuerySourceDropdownItem.propTypes = {
|
||||||
|
dataSource: PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
id: PropTypes.string,
|
||||||
|
type: PropTypes.string,
|
||||||
|
}).isRequired,
|
||||||
|
children: PropTypes.element,
|
||||||
|
};
|
||||||
|
|
||||||
|
registerComponent("QuerySourceDropdownItem", QuerySourceDropdownItem);
|
||||||
11
client/app/pages/queries/components/QuerySourceTypeIcon.jsx
Normal file
11
client/app/pages/queries/components/QuerySourceTypeIcon.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function QuerySourceTypeIcon(props) {
|
||||||
|
return <img src={`static/images/db-logos/${props.type}.png`} width="20" alt={props.alt} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
QuerySourceTypeIcon.propTypes = {
|
||||||
|
type: PropTypes.string,
|
||||||
|
alt: PropTypes.string,
|
||||||
|
};
|
||||||
@@ -91,6 +91,7 @@ export default function QueryVisualizationTabs({
|
|||||||
onAddVisualization,
|
onAddVisualization,
|
||||||
onDeleteVisualization,
|
onDeleteVisualization,
|
||||||
refreshButton,
|
refreshButton,
|
||||||
|
canRefresh,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
const visualizations = useMemo(
|
const visualizations = useMemo(
|
||||||
@@ -153,8 +154,12 @@ export default function QueryVisualizationTabs({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Query Has no Result"
|
title="Query has no result"
|
||||||
message="Execute/Refresh the query to show results."
|
message={
|
||||||
|
canRefresh
|
||||||
|
? "Execute/Refresh the query to show results."
|
||||||
|
: "You do not have a permission to execute/refresh this query."
|
||||||
|
}
|
||||||
refreshButton={refreshButton}
|
refreshButton={refreshButton}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -174,6 +179,7 @@ QueryVisualizationTabs.propTypes = {
|
|||||||
onAddVisualization: PropTypes.func,
|
onAddVisualization: PropTypes.func,
|
||||||
onDeleteVisualization: PropTypes.func,
|
onDeleteVisualization: PropTypes.func,
|
||||||
refreshButton: PropTypes.node,
|
refreshButton: PropTypes.node,
|
||||||
|
canRefresh: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
QueryVisualizationTabs.defaultProps = {
|
QueryVisualizationTabs.defaultProps = {
|
||||||
@@ -186,4 +192,5 @@ QueryVisualizationTabs.defaultProps = {
|
|||||||
onAddVisualization: () => {},
|
onAddVisualization: () => {},
|
||||||
onDeleteVisualization: () => {},
|
onDeleteVisualization: () => {},
|
||||||
refreshButton: null,
|
refreshButton: null,
|
||||||
|
canRefresh: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ function isAutoLimitAvailable(dataSource) {
|
|||||||
|
|
||||||
export default function useAutoLimitFlags(dataSource, query, setQuery) {
|
export default function useAutoLimitFlags(dataSource, query, setQuery) {
|
||||||
const isAvailable = isAutoLimitAvailable(dataSource);
|
const isAvailable = isAutoLimitAvailable(dataSource);
|
||||||
const [isChecked, setIsChecked] = useState(localOptions.get("applyAutoLimit", true));
|
const [isChecked, setIsChecked] = useState(query.options.apply_auto_limit);
|
||||||
query.options.apply_auto_limit = isAvailable && isChecked;
|
query.options.apply_auto_limit = isChecked;
|
||||||
|
|
||||||
const setAutoLimit = useCallback(
|
const setAutoLimit = useCallback(
|
||||||
state => {
|
state => {
|
||||||
setIsChecked(state);
|
setIsChecked(state);
|
||||||
localOptions.set("applyAutoLimit", state);
|
localOptions.set("applyAutoLimit", state);
|
||||||
setQuery(extend(query.clone(), { options: { ...query.options, apply_auto_limit: isAvailable && state } }));
|
setQuery(extend(query.clone(), { options: { ...query.options, apply_auto_limit: state } }));
|
||||||
},
|
},
|
||||||
[query, setQuery, isAvailable]
|
[query, setQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
return [isAvailable, isChecked, setAutoLimit];
|
return [isAvailable, isChecked, setAutoLimit];
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { extend, get } from "lodash";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { Query } from "@/services/query";
|
|
||||||
import notification from "@/services/notification";
|
|
||||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
|
||||||
|
|
||||||
export default function useFormatQuery(query, syntax, onChange) {
|
|
||||||
const handleChange = useImmutableCallback(onChange);
|
|
||||||
|
|
||||||
return useCallback(() => {
|
|
||||||
Query.format(syntax || "sql", query.query)
|
|
||||||
.then(queryText => {
|
|
||||||
handleChange(extend(query.clone(), { query: queryText }));
|
|
||||||
})
|
|
||||||
.catch(error =>
|
|
||||||
notification.error(get(error, "response.data.message", "Failed to format query: unknown error."))
|
|
||||||
);
|
|
||||||
}, [query, syntax, handleChange]);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isEmpty } from "lodash";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import useUpdateQuery from "./useUpdateQuery";
|
import useUpdateQuery from "./useUpdateQuery";
|
||||||
import navigateTo from "@/components/ApplicationArea/navigateTo";
|
import navigateTo from "@/components/ApplicationArea/navigateTo";
|
||||||
@@ -5,6 +6,7 @@ import navigateTo from "@/components/ApplicationArea/navigateTo";
|
|||||||
export default function useQuery(originalQuery) {
|
export default function useQuery(originalQuery) {
|
||||||
const [query, setQuery] = useState(originalQuery);
|
const [query, setQuery] = useState(originalQuery);
|
||||||
const [originalQuerySource, setOriginalQuerySource] = useState(originalQuery.query);
|
const [originalQuerySource, setOriginalQuerySource] = useState(originalQuery.query);
|
||||||
|
const [originalAutoLimit, setOriginalAutoLimit] = useState(query.options.apply_auto_limit);
|
||||||
|
|
||||||
const updateQuery = useUpdateQuery(query, updatedQuery => {
|
const updateQuery = useUpdateQuery(query, updatedQuery => {
|
||||||
// It's important to update URL first, and only then update state
|
// It's important to update URL first, and only then update state
|
||||||
@@ -14,15 +16,18 @@ export default function useQuery(originalQuery) {
|
|||||||
}
|
}
|
||||||
setQuery(updatedQuery);
|
setQuery(updatedQuery);
|
||||||
setOriginalQuerySource(updatedQuery.query);
|
setOriginalQuerySource(updatedQuery.query);
|
||||||
|
setOriginalAutoLimit(updatedQuery.options.apply_auto_limit);
|
||||||
});
|
});
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
query,
|
query,
|
||||||
setQuery,
|
setQuery,
|
||||||
isDirty: query.query !== originalQuerySource,
|
isDirty:
|
||||||
|
query.query !== originalQuerySource ||
|
||||||
|
(!isEmpty(query.query) && query.options.apply_auto_limit !== originalAutoLimit),
|
||||||
saveQuery: () => updateQuery(),
|
saveQuery: () => updateQuery(),
|
||||||
}),
|
}),
|
||||||
[query, originalQuerySource, updateQuery]
|
[query, originalQuerySource, updateQuery, originalAutoLimit]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isNil, isEmpty } from "lodash";
|
import { isNil, isEmpty } from "lodash";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { currentUser } from "@/services/auth";
|
import { currentUser } from "@/services/auth";
|
||||||
|
import { policy } from "@/services/policy";
|
||||||
|
|
||||||
export default function useQueryFlags(query, dataSource = null) {
|
export default function useQueryFlags(query, dataSource = null) {
|
||||||
dataSource = dataSource || { view_only: true };
|
dataSource = dataSource || { view_only: true };
|
||||||
@@ -15,10 +16,11 @@ export default function useQueryFlags(query, dataSource = null) {
|
|||||||
// permissions flags
|
// permissions flags
|
||||||
canCreate: currentUser.hasPermission("create_query"),
|
canCreate: currentUser.hasPermission("create_query"),
|
||||||
canView: currentUser.hasPermission("view_query"),
|
canView: currentUser.hasPermission("view_query"),
|
||||||
canEdit: currentUser.hasPermission("edit_query") && query.can_edit,
|
canEdit: currentUser.hasPermission("edit_query") && policy.canEdit(query),
|
||||||
canViewSource: currentUser.hasPermission("view_source"),
|
canViewSource: currentUser.hasPermission("view_source"),
|
||||||
canExecute:
|
canExecute:
|
||||||
!isEmpty(query.query) &&
|
!isEmpty(query.query) &&
|
||||||
|
policy.canRun(query) &&
|
||||||
(query.is_safe || (currentUser.hasPermission("execute_query") && !dataSource.view_only)),
|
(query.is_safe || (currentUser.hasPermission("execute_query") && !dataSource.view_only)),
|
||||||
canFork: currentUser.hasPermission("edit_query") && !dataSource.view_only,
|
canFork: currentUser.hasPermission("edit_query") && !dataSource.view_only,
|
||||||
canSchedule: currentUser.hasPermission("schedule_query"),
|
canSchedule: currentUser.hasPermission("schedule_query"),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Modal from "antd/lib/modal";
|
|||||||
import { Query } from "@/services/query";
|
import { Query } from "@/services/query";
|
||||||
import notification from "@/services/notification";
|
import notification from "@/services/notification";
|
||||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
||||||
|
import { policy } from "@/services/policy";
|
||||||
|
|
||||||
class SaveQueryError extends Error {
|
class SaveQueryError extends Error {
|
||||||
constructor(message, detailedMessage = null) {
|
constructor(message, detailedMessage = null) {
|
||||||
@@ -94,10 +95,11 @@ export default function useUpdateQuery(query, onChange) {
|
|||||||
"options",
|
"options",
|
||||||
"latest_query_data_id",
|
"latest_query_data_id",
|
||||||
"is_draft",
|
"is_draft",
|
||||||
|
"tags",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return doSaveQuery(data, { canOverwrite: query.can_edit })
|
return doSaveQuery(data, { canOverwrite: policy.canEdit(query) })
|
||||||
.then(updatedQuery => {
|
.then(updatedQuery => {
|
||||||
if (!isNil(successMessage)) {
|
if (!isNil(successMessage)) {
|
||||||
notification.success(successMessage);
|
notification.success(successMessage);
|
||||||
|
|||||||
@@ -1,89 +1,37 @@
|
|||||||
import { get } from "lodash";
|
import React from "react";
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
|
import Skeleton from "antd/lib/skeleton";
|
||||||
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
||||||
import LoadingState from "@/components/items-list/components/LoadingState";
|
|
||||||
import wrapSettingsTab from "@/components/SettingsWrapper";
|
import wrapSettingsTab from "@/components/SettingsWrapper";
|
||||||
|
|
||||||
import recordEvent from "@/services/recordEvent";
|
|
||||||
import OrgSettings from "@/services/organizationSettings";
|
|
||||||
import routes from "@/services/routes";
|
import routes from "@/services/routes";
|
||||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
|
||||||
import { getHorizontalFormProps, getHorizontalFormItemWithoutLabelProps } from "@/styles/formStyle";
|
import { getHorizontalFormProps, getHorizontalFormItemWithoutLabelProps } from "@/styles/formStyle";
|
||||||
|
|
||||||
|
import useOrganizationSettings from "./hooks/useOrganizationSettings";
|
||||||
import GeneralSettings from "./components/GeneralSettings";
|
import GeneralSettings from "./components/GeneralSettings";
|
||||||
import AuthSettings from "./components/AuthSettings";
|
import AuthSettings from "./components/AuthSettings";
|
||||||
|
|
||||||
function OrganizationSettings({ onError }) {
|
function OrganizationSettings({ onError }) {
|
||||||
const [settings, setSettings] = useState({});
|
const { settings, currentValues, isLoading, isSaving, handleSubmit, handleChange } = useOrganizationSettings(onError);
|
||||||
const [currentValues, setCurrentValues] = useState({});
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
const handleError = useImmutableCallback(onError);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
recordEvent("view", "page", "org_settings");
|
|
||||||
|
|
||||||
let isCancelled = false;
|
|
||||||
|
|
||||||
OrgSettings.get()
|
|
||||||
.then(response => {
|
|
||||||
if (!isCancelled) {
|
|
||||||
const settings = get(response, "settings");
|
|
||||||
setSettings(settings);
|
|
||||||
setCurrentValues({ ...settings });
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
if (!isCancelled) {
|
|
||||||
handleError(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isCancelled = true;
|
|
||||||
};
|
|
||||||
}, [handleError]);
|
|
||||||
|
|
||||||
const handleChange = useCallback(changes => {
|
|
||||||
setCurrentValues(currentValues => ({ ...currentValues, ...changes }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
|
||||||
if (!isSaving) {
|
|
||||||
setIsSaving(true);
|
|
||||||
OrgSettings.save(currentValues)
|
|
||||||
.then(response => {
|
|
||||||
const settings = get(response, "settings");
|
|
||||||
setSettings(settings);
|
|
||||||
setCurrentValues({ ...settings });
|
|
||||||
})
|
|
||||||
.catch(handleError)
|
|
||||||
.finally(() => setIsSaving(false));
|
|
||||||
}
|
|
||||||
}, [isSaving, currentValues, handleError]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row" data-test="OrganizationSettings">
|
<div className="row" data-test="OrganizationSettings">
|
||||||
<div className="m-r-20 m-l-20">
|
<div className="m-r-20 m-l-20">
|
||||||
{isLoading ? (
|
|
||||||
<LoadingState className="" />
|
|
||||||
) : (
|
|
||||||
<Form {...getHorizontalFormProps()} onFinish={handleSubmit}>
|
<Form {...getHorizontalFormProps()} onFinish={handleSubmit}>
|
||||||
<GeneralSettings settings={settings} values={currentValues} onChange={handleChange} />
|
<GeneralSettings loading={isLoading} settings={settings} values={currentValues} onChange={handleChange} />
|
||||||
<AuthSettings settings={settings} values={currentValues} onChange={handleChange} />
|
<AuthSettings loading={isLoading} settings={settings} values={currentValues} onChange={handleChange} />
|
||||||
<Form.Item {...getHorizontalFormItemWithoutLabelProps()}>
|
<Form.Item {...getHorizontalFormItemWithoutLabelProps()}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton.Button active />
|
||||||
|
) : (
|
||||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,19 +3,20 @@ import Alert from "antd/lib/alert";
|
|||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
import Checkbox from "antd/lib/checkbox";
|
import Checkbox from "antd/lib/checkbox";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
|
import Skeleton from "antd/lib/skeleton";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { clientConfig } from "@/services/auth";
|
import { clientConfig } from "@/services/auth";
|
||||||
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
||||||
|
|
||||||
export default function PasswordLoginSettings(props) {
|
export default function PasswordLoginSettings(props) {
|
||||||
const { settings, values, onChange } = props;
|
const { settings, values, onChange, loading } = props;
|
||||||
|
|
||||||
const isTheOnlyAuthMethod =
|
const isTheOnlyAuthMethod =
|
||||||
!clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !values.auth_saml_enabled;
|
!clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !values.auth_saml_enabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicComponent name="OrganizationSettings.PasswordLoginSettings" {...props}>
|
<DynamicComponent name="OrganizationSettings.PasswordLoginSettings" {...props}>
|
||||||
{!settings.auth_password_login_enabled && (
|
{!loading && !settings.auth_password_login_enabled && (
|
||||||
<Alert
|
<Alert
|
||||||
message="Password based login is currently disabled and users will
|
message="Password based login is currently disabled and users will
|
||||||
be able to login only with the enabled SSO options."
|
be able to login only with the enabled SSO options."
|
||||||
@@ -24,6 +25,9 @@ export default function PasswordLoginSettings(props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Form.Item label="Password Login">
|
<Form.Item label="Password Login">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton title={{ width: 300 }} paragraph={false} active />
|
||||||
|
) : (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={values.auth_password_login_enabled}
|
checked={values.auth_password_login_enabled}
|
||||||
disabled={isTheOnlyAuthMethod}
|
disabled={isTheOnlyAuthMethod}
|
||||||
@@ -36,6 +40,7 @@ export default function PasswordLoginSettings(props) {
|
|||||||
Password Login Enabled
|
Password Login Enabled
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,64 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
import Checkbox from "antd/lib/checkbox";
|
|
||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
|
import Skeleton from "antd/lib/skeleton";
|
||||||
|
import Radio from "antd/lib/radio";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
||||||
|
|
||||||
export default function SAMLSettings(props) {
|
export default function SAMLSettings(props) {
|
||||||
const { values, onChange } = props;
|
const { values, onChange, loading } = props;
|
||||||
|
|
||||||
|
const onChangeEnabledStatus = e => {
|
||||||
|
const updates = { auth_saml_enabled: !!e.target.value };
|
||||||
|
if (e.target.value) {
|
||||||
|
updates.auth_saml_type = e.target.value;
|
||||||
|
}
|
||||||
|
onChange(updates);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicComponent name="OrganizationSettings.SAMLSettings" {...props}>
|
<DynamicComponent name="OrganizationSettings.SAMLSettings" {...props}>
|
||||||
<h4>SAML</h4>
|
<h4>SAML</h4>
|
||||||
<Form.Item label="SAML Enabled">
|
<Form.Item label="SAML Enabled">
|
||||||
<Checkbox
|
{loading ? (
|
||||||
name="auth_saml_enabled"
|
<Skeleton title={{ width: 300 }} paragraph={false} active />
|
||||||
checked={values.auth_saml_enabled}
|
) : (
|
||||||
onChange={e => onChange({ auth_saml_enabled: e.target.checked })}>
|
<Radio.Group
|
||||||
SAML Enabled
|
onChange={onChangeEnabledStatus}
|
||||||
</Checkbox>
|
value={values.auth_saml_enabled && (values.auth_saml_type || "dynamic")}>
|
||||||
|
<Radio value={false}>Disabled</Radio>
|
||||||
|
<Radio value={"static"}>Enabled (Static)</Radio>
|
||||||
|
<Radio value={"dynamic"}>Enabled (Dynamic)</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{values.auth_saml_enabled && (
|
{values.auth_saml_enabled && (
|
||||||
<div>
|
<>
|
||||||
|
{values.auth_saml_type === "static" && (
|
||||||
|
<>
|
||||||
|
<Form.Item label="SAML Single Sign-on URL">
|
||||||
|
<Input
|
||||||
|
value={values.auth_saml_sso_url}
|
||||||
|
onChange={e => onChange({ auth_saml_sso_url: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="SAML Entity ID">
|
||||||
|
<Input
|
||||||
|
value={values.auth_saml_entity_id}
|
||||||
|
onChange={e => onChange({ auth_saml_entity_id: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="SAML x509 cert">
|
||||||
|
<Input
|
||||||
|
value={values.auth_saml_x509_cert}
|
||||||
|
onChange={e => onChange({ auth_saml_x509_cert: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{values.auth_saml_type === "dynamic" && (
|
||||||
|
<>
|
||||||
<Form.Item label="SAML Metadata URL">
|
<Form.Item label="SAML Metadata URL">
|
||||||
<Input
|
<Input
|
||||||
value={values.auth_saml_metadata_url}
|
value={values.auth_saml_metadata_url}
|
||||||
@@ -39,7 +77,9 @@ export default function SAMLSettings(props) {
|
|||||||
onChange={e => onChange({ auth_saml_nameid_format: e.target.value })}
|
onChange={e => onChange({ auth_saml_nameid_format: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
|
||||||
import Checkbox from "antd/lib/checkbox";
|
import Checkbox from "antd/lib/checkbox";
|
||||||
|
import Skeleton from "antd/lib/skeleton";
|
||||||
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
||||||
|
|
||||||
export default function BeaconConsentSettings(props) {
|
export default function BeaconConsentSettings(props) {
|
||||||
const { values, onChange } = props;
|
const { values, onChange, loading } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicComponent name="OrganizationSettings.BeaconConsentSettings" {...props}>
|
<DynamicComponent name="OrganizationSettings.BeaconConsentSettings" {...props}>
|
||||||
@@ -17,12 +18,16 @@ export default function BeaconConsentSettings(props) {
|
|||||||
<HelpTrigger className="m-l-5 m-r-5" type="USAGE_DATA_SHARING" />
|
<HelpTrigger className="m-l-5 m-r-5" type="USAGE_DATA_SHARING" />
|
||||||
</span>
|
</span>
|
||||||
}>
|
}>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton title={{ width: 300 }} paragraph={false} active />
|
||||||
|
) : (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
name="beacon_consent"
|
name="beacon_consent"
|
||||||
checked={values.beacon_consent}
|
checked={values.beacon_consent}
|
||||||
onChange={e => onChange({ beacon_consent: e.target.checked })}>
|
onChange={e => onChange({ beacon_consent: e.target.checked })}>
|
||||||
Help Redash improve by automatically sending anonymous usage data
|
Help Redash improve by automatically sending anonymous usage data
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,15 +2,24 @@ import React from "react";
|
|||||||
import Checkbox from "antd/lib/checkbox";
|
import Checkbox from "antd/lib/checkbox";
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
import Row from "antd/lib/row";
|
import Row from "antd/lib/row";
|
||||||
|
import Skeleton from "antd/lib/skeleton";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
||||||
|
|
||||||
export default function FeatureFlagsSettings(props) {
|
export default function FeatureFlagsSettings(props) {
|
||||||
const { values, onChange } = props;
|
const { values, onChange, loading } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicComponent name="OrganizationSettings.FeatureFlagsSettings" {...props}>
|
<DynamicComponent name="OrganizationSettings.FeatureFlagsSettings" {...props}>
|
||||||
<Form.Item label="Feature Flags">
|
<Form.Item label="Feature Flags">
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Row>
|
||||||
|
<Skeleton title={false} paragraph={{ width: [300, 300, 300], rows: 3 }} active />
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<DynamicComponent name="OrganizationSettings.FeatureFlagsSettings.PermissionsControl" {...props}>
|
<DynamicComponent name="OrganizationSettings.FeatureFlagsSettings.PermissionsControl" {...props}>
|
||||||
<Row>
|
<Row>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -37,6 +46,8 @@ export default function FeatureFlagsSettings(props) {
|
|||||||
Enable multi-byte (Chinese, Japanese, and Korean) search for query names and descriptions (slower)
|
Enable multi-byte (Chinese, Japanese, and Korean) search for query names and descriptions (slower)
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</Row>
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,15 +2,19 @@ import React from "react";
|
|||||||
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
import Select from "antd/lib/select";
|
import Select from "antd/lib/select";
|
||||||
|
import Skeleton from "antd/lib/skeleton";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { clientConfig } from "@/services/auth";
|
import { clientConfig } from "@/services/auth";
|
||||||
|
|
||||||
export default function FormatSettings(props) {
|
export default function FormatSettings(props) {
|
||||||
const { values, onChange } = props;
|
const { values, onChange, loading } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicComponent name="OrganizationSettings.FormatSettings" {...props}>
|
<DynamicComponent name="OrganizationSettings.FormatSettings" {...props}>
|
||||||
<Form.Item label="Date Format">
|
<Form.Item label="Date Format">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton.Input style={{ width: 300 }} active />
|
||||||
|
) : (
|
||||||
<Select
|
<Select
|
||||||
value={values.date_format}
|
value={values.date_format}
|
||||||
onChange={value => onChange({ date_format: value })}
|
onChange={value => onChange({ date_format: value })}
|
||||||
@@ -19,8 +23,12 @@ export default function FormatSettings(props) {
|
|||||||
<Select.Option key={dateFormat}>{dateFormat}</Select.Option>
|
<Select.Option key={dateFormat}>{dateFormat}</Select.Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="Time Format">
|
<Form.Item label="Time Format">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton.Input style={{ width: 300 }} active />
|
||||||
|
) : (
|
||||||
<Select
|
<Select
|
||||||
value={values.time_format}
|
value={values.time_format}
|
||||||
onChange={value => onChange({ time_format: value })}
|
onChange={value => onChange({ time_format: value })}
|
||||||
@@ -29,6 +37,7 @@ export default function FormatSettings(props) {
|
|||||||
<Select.Option key={timeFormat}>{timeFormat}</Select.Option>
|
<Select.Option key={timeFormat}>{timeFormat}</Select.Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Checkbox from "antd/lib/checkbox";
|
import Checkbox from "antd/lib/checkbox";
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
|
import Skeleton from "antd/lib/skeleton";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
||||||
|
|
||||||
export default function PlotlySettings(props) {
|
export default function PlotlySettings(props) {
|
||||||
const { values, onChange } = props;
|
const { values, onChange, loading } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicComponent name="OrganizationSettings.PlotlySettings" {...props}>
|
<DynamicComponent name="OrganizationSettings.PlotlySettings" {...props}>
|
||||||
<Form.Item label="Chart Visualization">
|
<Form.Item label="Chart Visualization">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton title={{ width: 300 }} paragraph={false} active />
|
||||||
|
) : (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
name="hide_plotly_mode_bar"
|
name="hide_plotly_mode_bar"
|
||||||
checked={values.hide_plotly_mode_bar}
|
checked={values.hide_plotly_mode_bar}
|
||||||
onChange={e => onChange({ hide_plotly_mode_bar: e.target.checked })}>
|
onChange={e => onChange({ hide_plotly_mode_bar: e.target.checked })}>
|
||||||
Hide Plotly mode bar
|
Hide Plotly mode bar
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ export const SettingsEditorPropTypes = {
|
|||||||
settings: PropTypes.object,
|
settings: PropTypes.object,
|
||||||
values: PropTypes.object,
|
values: PropTypes.object,
|
||||||
onChange: PropTypes.func, // (key, value) => void
|
onChange: PropTypes.func, // (key, value) => void
|
||||||
|
loading: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsEditorDefaultProps = {
|
export const SettingsEditorDefaultProps = {
|
||||||
settings: {},
|
settings: {},
|
||||||
values: {},
|
values: {},
|
||||||
onChange: () => {},
|
onChange: () => {},
|
||||||
|
loading: false,
|
||||||
};
|
};
|
||||||
|
|||||||
59
client/app/pages/settings/hooks/useOrganizationSettings.js
Normal file
59
client/app/pages/settings/hooks/useOrganizationSettings.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { get } from "lodash";
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import recordEvent from "@/services/recordEvent";
|
||||||
|
import OrgSettings from "@/services/organizationSettings";
|
||||||
|
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
||||||
|
|
||||||
|
export default function useOrganizationSettings({ onError }) {
|
||||||
|
const [settings, setSettings] = useState({});
|
||||||
|
const [currentValues, setCurrentValues] = useState({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleError = useImmutableCallback(onError);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
recordEvent("view", "page", "org_settings");
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
OrgSettings.get()
|
||||||
|
.then(response => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
const settings = get(response, "settings");
|
||||||
|
setSettings(settings);
|
||||||
|
setCurrentValues({ ...settings });
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [handleError]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(changes => {
|
||||||
|
setCurrentValues(currentValues => ({ ...currentValues, ...changes }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!isSaving) {
|
||||||
|
setIsSaving(true);
|
||||||
|
OrgSettings.save(currentValues)
|
||||||
|
.then(response => {
|
||||||
|
const settings = get(response, "settings");
|
||||||
|
setSettings(settings);
|
||||||
|
setCurrentValues({ ...settings });
|
||||||
|
})
|
||||||
|
.catch(handleError)
|
||||||
|
.finally(() => setIsSaving(false));
|
||||||
|
}
|
||||||
|
}, [isSaving, currentValues, handleError]);
|
||||||
|
|
||||||
|
return { settings, currentValues, isLoading, isSaving, handleSubmit, handleChange };
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { UserProfile } from "@/components/proptypes";
|
import { UserProfile } from "@/components/proptypes";
|
||||||
|
import UserGroups from "@/components/UserGroups";
|
||||||
|
|
||||||
import UserGroups from "./UserGroups";
|
|
||||||
import useUserGroups from "../hooks/useUserGroups";
|
import useUserGroups from "../hooks/useUserGroups";
|
||||||
|
|
||||||
export default function ReadOnlyUserProfile({ user }) {
|
export default function ReadOnlyUserProfile({ user }) {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import PropTypes from "prop-types";
|
|||||||
import { UserProfile } from "@/components/proptypes";
|
import { UserProfile } from "@/components/proptypes";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
||||||
|
import UserGroups from "@/components/UserGroups";
|
||||||
|
|
||||||
import User from "@/services/user";
|
import User from "@/services/user";
|
||||||
import { currentUser } from "@/services/auth";
|
import { currentUser } from "@/services/auth";
|
||||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
||||||
|
|
||||||
import UserGroups from "./UserGroups";
|
|
||||||
import useUserGroups from "../hooks/useUserGroups";
|
import useUserGroups from "../hooks/useUserGroups";
|
||||||
|
|
||||||
export default function UserInfoForm(props) {
|
export default function UserInfoForm(props) {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { axios } from "@/services/axios";
|
|||||||
import { notifySessionRestored } from "@/services/restoreSession";
|
import { notifySessionRestored } from "@/services/restoreSession";
|
||||||
|
|
||||||
export const currentUser = {
|
export const currentUser = {
|
||||||
|
_isAdmin: undefined,
|
||||||
|
|
||||||
canEdit(object) {
|
canEdit(object) {
|
||||||
const userId = object.user_id || (object.user && object.user.id);
|
const userId = object.user_id || (object.user && object.user.id);
|
||||||
return this.hasPermission("admin") || (userId && userId === this.id);
|
return this.isAdmin || (userId && userId === this.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
canCreate() {
|
canCreate() {
|
||||||
@@ -17,12 +19,19 @@ export const currentUser = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
hasPermission(permission) {
|
hasPermission(permission) {
|
||||||
|
if (permission === "admin" && this._isAdmin !== undefined) {
|
||||||
|
return this._isAdmin;
|
||||||
|
}
|
||||||
return includes(this.permissions, permission);
|
return includes(this.permissions, permission);
|
||||||
},
|
},
|
||||||
|
|
||||||
get isAdmin() {
|
get isAdmin() {
|
||||||
return this.hasPermission("admin");
|
return this.hasPermission("admin");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
set isAdmin(isAdmin) {
|
||||||
|
this._isAdmin = isAdmin;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clientConfig = {};
|
export const clientConfig = {};
|
||||||
|
|||||||
41
client/app/services/auth.test.js
Normal file
41
client/app/services/auth.test.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { currentUser } from "./auth";
|
||||||
|
|
||||||
|
describe("currentUser", () => {
|
||||||
|
describe("currentUser.isAdmin", () => {
|
||||||
|
it("returns state based on permission", () => {
|
||||||
|
currentUser.permissions = ["admin"];
|
||||||
|
|
||||||
|
expect(currentUser.isAdmin).toBeTruthy();
|
||||||
|
|
||||||
|
currentUser.permissions = [];
|
||||||
|
|
||||||
|
expect(currentUser.isAdmin).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows setting admin status explicitly", () => {
|
||||||
|
currentUser.permissions = [];
|
||||||
|
currentUser.isAdmin = true;
|
||||||
|
expect(currentUser.isAdmin).toBeTruthy();
|
||||||
|
currentUser.permissions = ["admin"];
|
||||||
|
currentUser.isAdmin = true;
|
||||||
|
expect(currentUser.isAdmin).toBeTruthy();
|
||||||
|
currentUser.permissions = ["admin"];
|
||||||
|
currentUser.isAdmin = false;
|
||||||
|
expect(currentUser.isAdmin).toBeFalsy();
|
||||||
|
currentUser.permissions = [];
|
||||||
|
currentUser.isAdmin = false;
|
||||||
|
expect(currentUser.isAdmin).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("currentUser.hasPermission", () => {
|
||||||
|
it("let's override admin status", () => {
|
||||||
|
currentUser.permissions = [""];
|
||||||
|
currentUser.isAdmin = true;
|
||||||
|
expect(currentUser.hasPermission("admin")).toBeTruthy();
|
||||||
|
currentUser.permissions = [""];
|
||||||
|
currentUser.isAdmin = false;
|
||||||
|
expect(currentUser.hasPermission("admin")).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,9 @@ import _ from "lodash";
|
|||||||
import { axios } from "@/services/axios";
|
import { axios } from "@/services/axios";
|
||||||
import dashboardGridOptions from "@/config/dashboard-grid-options";
|
import dashboardGridOptions from "@/config/dashboard-grid-options";
|
||||||
import Widget from "./widget";
|
import Widget from "./widget";
|
||||||
import { currentUser } from "@/services/auth";
|
|
||||||
import location from "@/services/location";
|
import location from "@/services/location";
|
||||||
import { cloneParameter } from "@/services/parameters";
|
import { cloneParameter } from "@/services/parameters";
|
||||||
|
import { policy } from "@/services/policy";
|
||||||
|
|
||||||
export const urlForDashboard = ({ id, slug }) => `dashboards/${id}-${slug}`;
|
export const urlForDashboard = ({ id, slug }) => `dashboards/${id}-${slug}`;
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ Dashboard.prepareDashboardWidgets = prepareDashboardWidgets;
|
|||||||
Dashboard.prepareWidgetsForDashboard = prepareWidgetsForDashboard;
|
Dashboard.prepareWidgetsForDashboard = prepareWidgetsForDashboard;
|
||||||
|
|
||||||
Dashboard.prototype.canEdit = function canEdit() {
|
Dashboard.prototype.canEdit = function canEdit() {
|
||||||
return currentUser.canEdit(this) || this.can_edit;
|
return policy.canEdit(this);
|
||||||
};
|
};
|
||||||
|
|
||||||
Dashboard.prototype.getParametersDefs = function getParametersDefs() {
|
Dashboard.prototype.getParametersDefs = function getParametersDefs() {
|
||||||
|
|||||||
21
client/app/services/notification.d.ts
vendored
21
client/app/services/notification.d.ts
vendored
@@ -1,10 +1,19 @@
|
|||||||
import { NotificationApi, ArgsProps } from "antd/lib/notification";
|
import { NotificationApi, ArgsProps } from "antd/lib/notification";
|
||||||
type simpleFunc = (message: string, description?: string | null, args?: ArgsProps | null) => void;
|
|
||||||
|
export type NotificationConfig = Omit<ArgsProps, "message" | "description"> | null;
|
||||||
|
|
||||||
|
type NotificationFunction = (
|
||||||
|
message: ArgsProps["message"],
|
||||||
|
description?: ArgsProps["description"],
|
||||||
|
args?: NotificationConfig
|
||||||
|
) => void;
|
||||||
|
|
||||||
declare const notification: NotificationApi & {
|
declare const notification: NotificationApi & {
|
||||||
success: simpleFunc;
|
success: NotificationFunction;
|
||||||
error: simpleFunc;
|
error: NotificationFunction;
|
||||||
info: simpleFunc;
|
info: NotificationFunction;
|
||||||
warning: simpleFunc;
|
warning: NotificationFunction;
|
||||||
warn: simpleFunc;
|
warn: NotificationFunction;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default notification;
|
export default notification;
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ const DATETIME_FORMATS = {
|
|||||||
|
|
||||||
const DYNAMIC_PREFIX = "d_";
|
const DYNAMIC_PREFIX = "d_";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic date range preset value with end set to current time
|
||||||
|
* @param from {function(): moment.Moment}
|
||||||
|
* @param now {function(): moment.Moment=} moment - defaults to now
|
||||||
|
* @returns {function(withNow: boolean): [moment.Moment, moment.Moment|undefined]}
|
||||||
|
*/
|
||||||
|
const untilNow = (from, now = () => moment()) => (withNow = true) => [from(), withNow ? now() : undefined];
|
||||||
|
|
||||||
const DYNAMIC_DATE_RANGES = {
|
const DYNAMIC_DATE_RANGES = {
|
||||||
today: {
|
today: {
|
||||||
name: "Today",
|
name: "Today",
|
||||||
@@ -72,29 +80,77 @@ const DYNAMIC_DATE_RANGES = {
|
|||||||
.endOf("year"),
|
.endOf("year"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
last_hour: {
|
||||||
|
name: "Last hour",
|
||||||
|
value: untilNow(() => moment().subtract(1, "hour")),
|
||||||
|
},
|
||||||
|
last_8_hours: {
|
||||||
|
name: "Last 8 hours",
|
||||||
|
value: untilNow(() => moment().subtract(8, "hour")),
|
||||||
|
},
|
||||||
|
last_24_hours: {
|
||||||
|
name: "Last 24 hours",
|
||||||
|
value: untilNow(() => moment().subtract(24, "hour")),
|
||||||
|
},
|
||||||
last_7_days: {
|
last_7_days: {
|
||||||
name: "Last 7 days",
|
name: "Last 7 days",
|
||||||
value: () => [moment().subtract(7, "days"), moment()],
|
value: untilNow(
|
||||||
|
() =>
|
||||||
|
moment()
|
||||||
|
.subtract(7, "days")
|
||||||
|
.startOf("day"),
|
||||||
|
() => moment().endOf("day")
|
||||||
|
),
|
||||||
},
|
},
|
||||||
last_14_days: {
|
last_14_days: {
|
||||||
name: "Last 14 days",
|
name: "Last 14 days",
|
||||||
value: () => [moment().subtract(14, "days"), moment()],
|
value: untilNow(
|
||||||
|
() =>
|
||||||
|
moment()
|
||||||
|
.subtract(14, "days")
|
||||||
|
.startOf("day"),
|
||||||
|
() => moment().endOf("day")
|
||||||
|
),
|
||||||
},
|
},
|
||||||
last_30_days: {
|
last_30_days: {
|
||||||
name: "Last 30 days",
|
name: "Last 30 days",
|
||||||
value: () => [moment().subtract(30, "days"), moment()],
|
value: untilNow(
|
||||||
|
() =>
|
||||||
|
moment()
|
||||||
|
.subtract(30, "days")
|
||||||
|
.startOf("day"),
|
||||||
|
() => moment().endOf("day")
|
||||||
|
),
|
||||||
},
|
},
|
||||||
last_60_days: {
|
last_60_days: {
|
||||||
name: "Last 60 days",
|
name: "Last 60 days",
|
||||||
value: () => [moment().subtract(60, "days"), moment()],
|
value: untilNow(
|
||||||
|
() =>
|
||||||
|
moment()
|
||||||
|
.subtract(60, "days")
|
||||||
|
.startOf("day"),
|
||||||
|
() => moment().endOf("day")
|
||||||
|
),
|
||||||
},
|
},
|
||||||
last_90_days: {
|
last_90_days: {
|
||||||
name: "Last 90 days",
|
name: "Last 90 days",
|
||||||
value: () => [moment().subtract(90, "days"), moment()],
|
value: untilNow(
|
||||||
|
() =>
|
||||||
|
moment()
|
||||||
|
.subtract(90, "days")
|
||||||
|
.startOf("day"),
|
||||||
|
() => moment().endOf("day")
|
||||||
|
),
|
||||||
},
|
},
|
||||||
last_12_months: {
|
last_12_months: {
|
||||||
name: "Last 12 months",
|
name: "Last 12 months",
|
||||||
value: () => [moment().subtract(12, "months"), moment()],
|
value: untilNow(
|
||||||
|
() =>
|
||||||
|
moment()
|
||||||
|
.subtract(12, "months")
|
||||||
|
.startOf("day"),
|
||||||
|
() => moment().endOf("day")
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,6 +163,11 @@ export function isDynamicDateRangeString(value) {
|
|||||||
return !!DYNAMIC_DATE_RANGES[value.substring(DYNAMIC_PREFIX.length)];
|
return !!DYNAMIC_DATE_RANGES[value.substring(DYNAMIC_PREFIX.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDynamicDateRangeStringFromName(dynamicRangeName) {
|
||||||
|
const key = findKey(DYNAMIC_DATE_RANGES, range => range.name === dynamicRangeName);
|
||||||
|
return key ? DYNAMIC_PREFIX + key : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function isDynamicDateRange(value) {
|
export function isDynamicDateRange(value) {
|
||||||
return includes(DYNAMIC_DATE_RANGES, value);
|
return includes(DYNAMIC_DATE_RANGES, value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { isNull, isObject, isFunction, isUndefined, isEqual, has, omit, isArray, each } from "lodash";
|
import { isNull, isObject, isFunction, isUndefined, isEqual, has, omit, isArray, each } from "lodash";
|
||||||
|
import { toHuman } from "@/lib/utils";
|
||||||
|
|
||||||
class Parameter {
|
class Parameter {
|
||||||
constructor(parameter, parentQueryId) {
|
constructor(parameter, parentQueryId) {
|
||||||
@@ -44,6 +45,10 @@ class Parameter {
|
|||||||
return this.$$value;
|
return this.$$value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTitle() {
|
||||||
|
return this.title || toHuman(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
isEmptyValue(value) {
|
isEmptyValue(value) {
|
||||||
return isNull(this.normalizeValue(value));
|
return isNull(this.normalizeValue(value));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,70 @@
|
|||||||
import { isNull, isUndefined, isArray, isEmpty, get, map, join, has } from "lodash";
|
import {
|
||||||
|
isNull,
|
||||||
|
isUndefined,
|
||||||
|
isArray,
|
||||||
|
isEmpty,
|
||||||
|
get,
|
||||||
|
map,
|
||||||
|
join,
|
||||||
|
has,
|
||||||
|
toString,
|
||||||
|
findKey,
|
||||||
|
mapValues,
|
||||||
|
pickBy,
|
||||||
|
filter,
|
||||||
|
omit,
|
||||||
|
} from "lodash";
|
||||||
import { Query } from "@/services/query";
|
import { Query } from "@/services/query";
|
||||||
|
import QueryResult from "@/services/query-result";
|
||||||
import Parameter from "./Parameter";
|
import Parameter from "./Parameter";
|
||||||
|
|
||||||
|
function mapQueryResultToDropdownOptions(options) {
|
||||||
|
return map(options, ({ label, name, value }) => ({ label: label || name, value: toString(value) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueryBasedParameterMappingType = {
|
||||||
|
DROPDOWN_SEARCH: "search",
|
||||||
|
STATIC: "static",
|
||||||
|
UNDEFINED: "undefined",
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractOptionLabelsFromValues(values) {
|
||||||
|
if (!isArray(values)) {
|
||||||
|
values = [values];
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionLabels = {};
|
||||||
|
values.forEach(val => {
|
||||||
|
if (has(val, "label") && has(val, "value")) {
|
||||||
|
optionLabels[val.value] = val.label;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return optionLabels;
|
||||||
|
}
|
||||||
|
|
||||||
class QueryBasedDropdownParameter extends Parameter {
|
class QueryBasedDropdownParameter extends Parameter {
|
||||||
constructor(parameter, parentQueryId) {
|
constructor(parameter, parentQueryId) {
|
||||||
super(parameter, parentQueryId);
|
super(parameter, parentQueryId);
|
||||||
this.queryId = parameter.queryId;
|
this.queryId = parameter.queryId;
|
||||||
this.multiValuesOptions = parameter.multiValuesOptions;
|
this.multiValuesOptions = parameter.multiValuesOptions;
|
||||||
|
this.parameterMapping = parameter.parameterMapping;
|
||||||
|
this.$$optionLabels = extractOptionLabelsFromValues(parameter.value);
|
||||||
this.setValue(parameter.value);
|
this.setValue(parameter.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get searchColumn() {
|
||||||
|
return findKey(this.parameterMapping, { mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH });
|
||||||
|
}
|
||||||
|
|
||||||
|
get staticParams() {
|
||||||
|
const staticParams = pickBy(
|
||||||
|
this.parameterMapping,
|
||||||
|
mapping => mapping.mappingType === QueryBasedParameterMappingType.STATIC
|
||||||
|
);
|
||||||
|
return mapValues(staticParams, value => value.staticValue);
|
||||||
|
}
|
||||||
|
|
||||||
normalizeValue(value) {
|
normalizeValue(value) {
|
||||||
if (isUndefined(value) || isNull(value) || (isArray(value) && isEmpty(value))) {
|
if (isUndefined(value) || isNull(value) || (isArray(value) && isEmpty(value))) {
|
||||||
return null;
|
return null;
|
||||||
@@ -20,24 +75,48 @@ class QueryBasedDropdownParameter extends Parameter {
|
|||||||
} else {
|
} else {
|
||||||
value = isArray(value) ? value[0] : value;
|
value = isArray(value) ? value[0] : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.searchColumn) {
|
||||||
|
value = this._getLabeledValue(value);
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setValue(value) {
|
||||||
|
if (this.searchColumn) {
|
||||||
|
value = this._getLabeledValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.setValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
getExecutionValue(extra = {}) {
|
getExecutionValue(extra = {}) {
|
||||||
const { joinListValues } = extra;
|
const { joinListValues } = extra;
|
||||||
if (joinListValues && isArray(this.value)) {
|
let executionValue = this.value;
|
||||||
|
if (isArray(executionValue)) {
|
||||||
|
executionValue = map(executionValue, value => get(value, "value", value));
|
||||||
|
|
||||||
|
if (joinListValues) {
|
||||||
const separator = get(this.multiValuesOptions, "separator", ",");
|
const separator = get(this.multiValuesOptions, "separator", ",");
|
||||||
const prefix = get(this.multiValuesOptions, "prefix", "");
|
const prefix = get(this.multiValuesOptions, "prefix", "");
|
||||||
const suffix = get(this.multiValuesOptions, "suffix", "");
|
const suffix = get(this.multiValuesOptions, "suffix", "");
|
||||||
const parameterValues = map(this.value, v => `${prefix}${v}${suffix}`);
|
const parameterValues = map(executionValue, v => `${prefix}${v}${suffix}`);
|
||||||
return join(parameterValues, separator);
|
executionValue = join(parameterValues, separator);
|
||||||
}
|
}
|
||||||
return this.value;
|
return executionValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
executionValue = get(executionValue, "value", executionValue);
|
||||||
|
return executionValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
toUrlParams() {
|
toUrlParams() {
|
||||||
const prefix = this.urlPrefix;
|
const prefix = this.urlPrefix;
|
||||||
|
|
||||||
|
if (this.searchColumn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let urlParam = this.value;
|
let urlParam = this.value;
|
||||||
if (this.multiValuesOptions && isArray(this.value)) {
|
if (this.multiValuesOptions && isArray(this.value)) {
|
||||||
urlParam = JSON.stringify(this.value);
|
urlParam = JSON.stringify(this.value);
|
||||||
@@ -51,28 +130,80 @@ class QueryBasedDropdownParameter extends Parameter {
|
|||||||
fromUrlParams(query) {
|
fromUrlParams(query) {
|
||||||
const prefix = this.urlPrefix;
|
const prefix = this.urlPrefix;
|
||||||
const key = `${prefix}${this.name}`;
|
const key = `${prefix}${this.name}`;
|
||||||
|
|
||||||
|
if (this.searchColumn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (has(query, key)) {
|
if (has(query, key)) {
|
||||||
|
const queryKey = query[key];
|
||||||
if (this.multiValuesOptions) {
|
if (this.multiValuesOptions) {
|
||||||
try {
|
try {
|
||||||
const valueFromJson = JSON.parse(query[key]);
|
const valueFromJson = JSON.parse(queryKey);
|
||||||
this.setValue(isArray(valueFromJson) ? valueFromJson : query[key]);
|
this.setValue(isArray(valueFromJson) ? valueFromJson : queryKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.setValue(query[key]);
|
this.setValue(queryKey);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.setValue(query[key]);
|
this.setValue(queryKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDropdownValues() {
|
_saveLabeledValuesFromOptions(options) {
|
||||||
if (this.parentQueryId) {
|
this.$$optionLabels = { ...this.$$optionLabels, ...extractOptionLabelsFromValues(options) };
|
||||||
return Query.associatedDropdown({ queryId: this.parentQueryId, dropdownQueryId: this.queryId }).catch(() =>
|
return options;
|
||||||
Promise.resolve([])
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Query.asDropdown({ id: this.queryId }).catch(Promise.resolve([]));
|
_getLabeledValue(value) {
|
||||||
|
const getSingleLabeledValue = value => {
|
||||||
|
value = get(value, "value", value);
|
||||||
|
if (!(value in this.$$optionLabels)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { value, label: this.$$optionLabels[value] };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isArray(value)) {
|
||||||
|
value = map(value, getSingleLabeledValue);
|
||||||
|
return filter(value); // remove values without label
|
||||||
|
}
|
||||||
|
return getSingleLabeledValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDropdownValues(initialSearchTerm = null) {
|
||||||
|
return Query.get({ id: this.queryId })
|
||||||
|
.then(query => {
|
||||||
|
const queryHasParameters = query.hasParameters();
|
||||||
|
if (queryHasParameters && this.searchColumn) {
|
||||||
|
this.searchFunction = searchTerm =>
|
||||||
|
QueryResult.getByQueryId(query.id, { ...this.staticParams, [this.searchColumn]: searchTerm }, -1)
|
||||||
|
.toPromise()
|
||||||
|
.then(result => get(result, "query_result.data.rows"))
|
||||||
|
.then(mapQueryResultToDropdownOptions)
|
||||||
|
.then(options => this._saveLabeledValuesFromOptions(options))
|
||||||
|
.catch(() => Promise.resolve([]));
|
||||||
|
return initialSearchTerm ? this.searchFunction(initialSearchTerm) : Promise.resolve([]);
|
||||||
|
} else {
|
||||||
|
this.searchFunction = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryHasParameters) {
|
||||||
|
return QueryResult.getByQueryId(query.id, { ...this.staticParams }, -1)
|
||||||
|
.toPromise()
|
||||||
|
.then(result => get(result, "query_result.data.rows"));
|
||||||
|
} else if (this.parentQueryId) {
|
||||||
|
return Query.associatedDropdown({ queryId: this.parentQueryId, dropdownQueryId: this.queryId });
|
||||||
|
}
|
||||||
|
return Query.asDropdown({ id: this.queryId });
|
||||||
|
})
|
||||||
|
.then(mapQueryResultToDropdownOptions)
|
||||||
|
.catch(() => Promise.resolve([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
toSaveableObject() {
|
||||||
|
const saveableObject = super.toSaveableObject();
|
||||||
|
return omit(saveableObject, ["$$optionLabels"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ describe("QueryBasedDropdownParameter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getExecutionValue", () => {
|
||||||
|
test("returns value when stored value doesn't contain its label", () => {
|
||||||
|
param.setValue("test");
|
||||||
|
expect(param.getExecutionValue()).toBe("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns value from object when stored value contains its label", () => {
|
||||||
|
param.setValue({ label: "Test Label", value: "test" });
|
||||||
|
expect(param.getExecutionValue()).toBe("test");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Multi-valued", () => {
|
describe("Multi-valued", () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
multiValuesOptions = { prefix: '"', suffix: '"', separator: "," };
|
multiValuesOptions = { prefix: '"', suffix: '"', separator: "," };
|
||||||
@@ -44,6 +56,19 @@ describe("QueryBasedDropdownParameter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getExecutionValue", () => {
|
describe("getExecutionValue", () => {
|
||||||
|
test("returns value when stored value doesn't contain its label", () => {
|
||||||
|
param.setValue(["test1", "test2"]);
|
||||||
|
expect(param.getExecutionValue()).toEqual(["test1", "test2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns value from object when stored value contains its label", () => {
|
||||||
|
param.setValue([
|
||||||
|
{ label: "Test Label 1", value: "test1" },
|
||||||
|
{ label: "Test Label 2", value: "test2" },
|
||||||
|
]);
|
||||||
|
expect(param.getExecutionValue()).toEqual(["test1", "test2"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("joins values when joinListValues is truthy", () => {
|
test("joins values when joinListValues is truthy", () => {
|
||||||
param.setValue(["value1", "value3"]);
|
param.setValue(["value1", "value3"]);
|
||||||
const executionValue = param.getExecutionValue({ joinListValues: true });
|
const executionValue = param.getExecutionValue({ joinListValues: true });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isArray } from "lodash";
|
import { get, isArray } from "lodash";
|
||||||
import { currentUser, clientConfig } from "@/services/auth";
|
import { currentUser, clientConfig } from "@/services/auth";
|
||||||
|
|
||||||
/* eslint-disable class-methods-use-this */
|
/* eslint-disable class-methods-use-this */
|
||||||
@@ -57,4 +57,12 @@ export default class DefaultPolicy {
|
|||||||
const result = clientConfig.queryRefreshIntervals;
|
const result = clientConfig.queryRefreshIntervals;
|
||||||
return isArray(result) ? result : null;
|
return isArray(result) ? result : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canEdit(object) {
|
||||||
|
return get(object, "can_edit", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
canRun() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import location from "@/services/location";
|
|||||||
import { Parameter, createParameter } from "./parameters";
|
import { Parameter, createParameter } from "./parameters";
|
||||||
import { currentUser } from "./auth";
|
import { currentUser } from "./auth";
|
||||||
import QueryResult from "./query-result";
|
import QueryResult from "./query-result";
|
||||||
|
import localOptions from "@/lib/localOptions";
|
||||||
|
|
||||||
Mustache.escape = identity; // do not html-escape values
|
Mustache.escape = identity; // do not html-escape values
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export class Query {
|
|||||||
if (!has(this, "options")) {
|
if (!has(this, "options")) {
|
||||||
this.options = {};
|
this.options = {};
|
||||||
}
|
}
|
||||||
|
this.options.apply_auto_limit = !!this.options.apply_auto_limit;
|
||||||
|
|
||||||
if (!isArray(this.options.parameters)) {
|
if (!isArray(this.options.parameters)) {
|
||||||
this.options.parameters = [];
|
this.options.parameters = [];
|
||||||
@@ -400,25 +402,10 @@ QueryService.newQuery = function newQuery() {
|
|||||||
name: "New Query",
|
name: "New Query",
|
||||||
schedule: null,
|
schedule: null,
|
||||||
user: currentUser,
|
user: currentUser,
|
||||||
options: {},
|
options: { apply_auto_limit: localOptions.get("applyAutoLimit", true) },
|
||||||
tags: [],
|
tags: [],
|
||||||
can_edit: true,
|
can_edit: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
QueryService.format = function formatQuery(syntax, query) {
|
|
||||||
if (syntax === "json") {
|
|
||||||
try {
|
|
||||||
const formatted = JSON.stringify(JSON.parse(query), " ", 4);
|
|
||||||
return Promise.resolve(formatted);
|
|
||||||
} catch (err) {
|
|
||||||
return Promise.reject(String(err));
|
|
||||||
}
|
|
||||||
} else if (syntax === "sql") {
|
|
||||||
return axios.post("api/queries/format", { query }).then(data => data.query);
|
|
||||||
} else {
|
|
||||||
return Promise.reject("Query formatting is not supported for your data source syntax.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
extend(Query, QueryService);
|
extend(Query, QueryService);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user