Compare commits

...

52 Commits

Author SHA1 Message Date
Gabriel Dutra
19343a0520 Clear QueryBasedParameterInput 2020-11-23 19:18:35 -03:00
Gabriel Dutra
c1ed8848f0 Merge branch 'master' into query-based-dropdown--parameters 2020-11-23 16:42:06 -03:00
Gabriel Dutra
b40070d7f5 Use LabeledValues for parameterized queries 2020-11-23 16:40:18 -03:00
Rafael Wendel
fa2b57a209 Remove unwanted props from Select component (#5277)
* Explicitly selected props so as to avoid errors from non-wanted props

* Simplified approach

* Ran prettier 😬

* Fixed minor issues
2020-11-22 13:07:56 -03:00
Jiajie Zhong
132fed64b3 Correct cleanup_query_results comment (#5276)
Correct comment from QUERY_RESULTS_MAX_AGE
to QUERY_RESULTS_CLEANUP_MAX_AGE
2020-11-20 23:11:13 +02:00
Gabriel Dutra
bd9ce68f68 Don't filter out values when param has search 2020-11-20 15:14:17 -03:00
Gabriel Dutra
0c0b62ae1a Remove searchTerm from structure 2020-11-20 15:13:47 -03:00
Gabriel Dutra
08bcdf77d0 Mock query instead of query_has_parameters 2020-11-12 09:11:54 -03:00
Gabriel Dutra
aa2064b1ab Fix other dropdown_values usages to use query obj 2020-11-11 13:58:01 -03:00
Gabriel Dutra
d0a787cab1 Make NoResultFound invalid parameters 2020-11-10 22:10:47 -03:00
Gabriel Dutra
a741341938 Oops 2020-11-10 20:46:51 -03:00
Gabriel Dutra
53385fa24b Merge branch 'master' into query-based-dropdown--parameters 2020-11-10 15:19:43 -03:00
Gabriel Dutra
fa7ecca485 Frontend updates from internal fork (#5259)
* DynamicComponent for QuerySourceAlerts

* General Settings updates

* Dynamic Date[Range] updates

* EmptyState updates

* Query and SchemaBrowser updates

* Adjust page headers and add disablePublish

* Policy updates

* Separate Home FavoritesList component

* Update FormatQuery

* Autolimit frontend fixes

* Misc updates

* Keep registering of QuerySourceDropdown

* Undo changes in DynamicComponent

* Change sql-formatter package.json syntax

* Allow opening help trigger in new tab

* Don't run npm commands as root in Dockerfile

* Cypress: Remove extra execute query
2020-11-10 14:59:15 +02:00
deecay
8f484706b1 Enable Boxplot to be horizontal (#5262) 2020-11-08 23:17:08 +02:00
Josh Bohde
e2e8714155 Enable graceful shutdown of rq workers (#5214)
* Enable graceful shutdown of rq workers

* Use `exec` in the `worker` command of the entrypoint to propagate
  the `TERM` signal
* Allow rq processes managed by supervisor to exit without restart on
  expected status codes
* Allow supervisorctl to contact the running supervisor
* Add a `shutdown_worker` command that will send `TERM` to all running
  worker processes and then sleep. This allows orchestration systems
  to initiate a graceful shutdown before sending `SIGTERM` to
  supervisord

* Use Heroku worker as the BaseWorker

This implements a graceful shutdown on SIGTERM, which simplifies
external shutdown procedures.

* Fix imports based upon review

* Remove supervisorctl config
2020-11-05 11:49:45 +02:00
Jerry
c6bf8a1c55 bugfix: fix #5254 (#5255)
Co-authored-by: Jerry <jerry.yuan@webweye.com>
2020-11-04 20:56:41 +02:00
Rafael Wendel
12f71925c2 Multiselect dropdown slowness (fix) (#5221)
* created util to estimate reasonable width for dropdown

* removed unused import

* improved calculation of item percentile

* added getItemOfPercentileLength to relevant spots

* added getItemOfPercentileLength to relevant spots

* Added missing import

* created custom select element

* added check for property path

* removed uses of percentile util

* gave up on getting element reference

* finished testing Select component

* removed unused imports

* removed older uses of Option component

* added canvas calculation

* removed minWidth from Select

* improved calculation

* added fallbacks

* added estimated offset

* removed leftovers 😅

* replaced to percentiles to max value

* switched to memo and renamed component

* proper useMemo syntax

* Update client/app/components/Select.tsx

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* created custom restrictive types

* added quick const

* fixed style

* fixed generics

* added pos absolute to fix percy

* removed custom select from ParameterMappingInput

* applied prettier

* Revert "added pos absolute to fix percy"

This reverts commit 4daf1d4bef.

* Pin Percy version to 0.24.3

* Update client/app/components/ParameterMappingInput.jsx

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* renamed Select.jsx to SelectWithVirtualScroll

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-11-03 21:50:39 +02:00
Omer Lachish
cae088f35b extend the refresh_queries timeout from 3 minutes to 10 minutes (#5253) 2020-11-02 22:36:57 +02:00
Rafael Wendel
a3c79f26b9 Fix for the typo button in ParameterMappingInput (#5244) 2020-10-29 17:24:13 -03:00
Jonathan Hult
c7c92a3192 Fix annotation bug causing queries not to run - ORA-00933 (#5179) 2020-10-28 10:03:26 +02:00
Rafael Wendel
55cf17aa47 added required to Form.Item and Input for better UI (#5231)
* added required to Form.Item and Input for better UI

* removed required from input

* Revert "removed required from input"

This reverts commit b56cd76fa1.

* Redo "removed required from input"

* removed typo

Co-authored-by: rafawendel2010@gmail.com <rafawendel>
2020-10-28 09:37:16 +02:00
Levko Kravets
8dd76a00c5 Fix dashboard background grid (#5238) 2020-10-26 21:46:38 +02:00
Christopher Grant
e242ac2b10 Static SAML configuration and assertion encryption (#5175)
* Change front-end and data model for SAML2 auth - static configuration

* Add changes to use inline metadata.

* add switch for static and dynamic SAML configurations

* Fixed config of backend static/dynamic to match UI

* add ability to encrypt/decrypt SAML assertions with pem and crt files. Upgraded to pysaml2 6.1.0 to mitigate signature mismatch during decryption

* remove print debug statement

* Use utility to find xmlsec binary for encryption, formatting saml_auth module

* format SAML Javascript, revert want_signed_response to pre-PR value

* pysaml2's entityid should point to the sp, not the idp

* add logging for entityid for validation

* use mustache_render instead of string formatting. put all static logic into static branch

* move mustache template for inline saml metadata to the global level

* Incorporate SAML type with Enabled setting

* Update client/app/pages/settings/components/AuthSettings/SAMLSettings.jsx

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

Co-authored-by: Chad Chen <chad.chen@databricks.com>
Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-10-25 12:06:45 -03:00
Gabriel Dutra
66463aedd4 Fix Home EmptyState help link (#5217) 2020-10-16 11:53:21 -03:00
Rafael Wendel
8a6524c1ba Add horizontal bar chart (#5154)
* added bar chart boilerplate

* added x/y manipulation

* replaced x/y management to inner series preparer

* added tests

* moved axis inversion to all charts series

* removed line and area

* inverted labels ui

* removed normalizer check, simplified inverted axes check

* finished working hbar

* minor review

* added conditional title to YAxis

* generalized horizontal chart for line charts, resetted state on globalSeriesType change

* fixed updates

* fixed updates to layout

* fixed minor issues

* removed right Y axis when axes inverted

* ran prettier

* fixed updater function conflict and misuse of getOptions

* renamed inverted to swapped

* created mappingtypes for swapped columns

* removed unused import

* minor polishing

* improved series behaviour in h-bar

* minor fix

* added basic filter to ChartTypeSelect

* final setup of filtered chart types

* Update viz-lib/src/components/visualizations/editor/createTabbedEditor.jsx

* added proptypes and renamed ChartTypeSelect props

* Add missing import

* fixed import, moved result array to global scope

* merged import

* clearer naming in ChartTypeSelect

* better lodash map syntax

* fixed global modification

* moved result inside useMemo

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
Co-authored-by: Levko Kravets <levko.ne@gmail.com>
2020-10-15 21:34:38 +03:00
Gabriel Dutra
9097feb100 Frontend updates from internal fork (#5209) 2020-10-15 14:25:22 -03:00
Gabriel Dutra
db4e97fa6f Remove build args from Cypress start script (#5203) 2020-10-09 12:23:14 -03:00
Levko Kravets
0d4615a482 Extra actions on Queries and Dashboards pages (#5201)
* Extra actions for Query View and Query Source pages

* Convert Queries List page to functional component

* Convert Dashboards List page to functional component

* Extra actions for Query List page

* Extra actions for Dashboard List page

* Extra actions for Dashboard page

* Pass some extra data to Dashboard.HeaderExtra component

* CR1
2020-10-09 12:12:56 +03:00
Alexander Rusanov
ff008a076b Updated Cypress to v5.3 and fixed e2e tests (#5199)
* Upgraded Cypress to v5.3 and fixed e2e tests

* Updated cypress image

* Fixed failing tests

* Updated NODE_VERSION in netlify

* Update client/cypress/integration/visualizations/choropleth_spec.js

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* fixed test in choropleth

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-10-06 16:06:47 -03:00
Gabriel Dutra
8d548ecbac Share Embed Spec: Make sure query is executed (#5191) 2020-10-04 16:01:30 +03:00
Gabriel Dutra
2992c382d1 ScheduleDialog: Filter empty interval groups (#5196) 2020-10-03 05:54:05 +03:00
Gabriel Dutra
f396c96457 Merge branch 'master' into query-based-dropdown--parameters 2020-02-25 07:49:36 -03:00
Gabriel Dutra
8bfcbf21e3 Remove redundant import 2020-02-24 11:44:28 -03:00
Gabriel Dutra
8a1640c4e7 Separate InputPopover component 2020-02-24 11:44:18 -03:00
Gabriel Dutra
a37e7f93dc Add is_safe test for queries with params 2020-02-22 15:47:29 -03:00
Gabriel Dutra
cc34e781d3 Small updates
- Change searchTerm separator
- Add cy.wait
2020-02-22 15:23:43 -03:00
Gabriel Dutra
6aa0ea715e Invert tooltip messages order 2020-02-22 14:08:19 -03:00
Gabriel Dutra
6c27619671 Make Parameter Mapping required in UI 2020-02-21 23:00:26 -03:00
Gabriel Dutra
6eeb3b3eb2 Separate UI components 2020-02-21 15:49:23 -03:00
Gabriel Dutra
d40edb81c2 Fix backend tests 2020-02-21 14:18:59 -03:00
Gabriel Dutra
f128b4b85f Only allow search for Text Parameters 2020-02-21 13:36:06 -03:00
Gabriel Dutra
264fb5798d Merge branch 'master' into query-based-dropdown--parameters 2020-02-21 13:31:49 -03:00
Gabriel Dutra
90023ac435 Make sure Table updates correctly 2020-02-21 11:03:37 -03:00
Gabriel Dutra
df755fbc17 Add try except for NoResultFound 2020-02-21 09:40:52 -03:00
Gabriel Dutra
e555642844 Add is_safe check for parameterized query based 2020-02-21 09:27:12 -03:00
Gabriel Dutra
bdd7b146ae Change stored mapping attributes 2020-02-20 21:59:19 -03:00
Gabriel Dutra
b7478defec Don't validade query params with params 2020-02-20 19:29:43 -03:00
Gabriel Dutra
bb0d7830c9 Fixes + temp remove validation for Query param 2020-02-20 18:49:29 -03:00
Gabriel Dutra
137aa22dd4 Parameter Mapping UI (2/2) 2020-02-18 17:55:27 -03:00
Gabriel Dutra
9cf396599a Parameter Mapping UI (1/2) 2020-02-17 23:32:10 -03:00
Gabriel Dutra
b70f0fa921 Iterate over backend verification 2020-02-16 13:39:05 -03:00
Gabriel Dutra
5e3613d6cb Start experiements with a 'search' parameter 2020-02-13 16:29:50 -03:00
144 changed files with 3354 additions and 1659 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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() {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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>
); );
} }

View File

@@ -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,
}; };

View File

@@ -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,
};

View File

@@ -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;
} }

View File

@@ -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;
}; };

View File

@@ -1,5 +1,5 @@
import { includes, words, capitalize, clone, isNull } from "lodash"; import { includes, words, capitalize, clone, isNull, map, get, find } from "lodash";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef, useMemo } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Checkbox from "antd/lib/checkbox"; import Checkbox from "antd/lib/checkbox";
import Modal from "antd/lib/modal"; import Modal from "antd/lib/modal";
@@ -11,6 +11,8 @@ import Divider from "antd/lib/divider";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import QuerySelector from "@/components/QuerySelector"; import QuerySelector from "@/components/QuerySelector";
import { Query } from "@/services/query"; import { Query } from "@/services/query";
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QueryBasedParameterMappingTable from "./query-based-parameter/QueryBasedParameterMappingTable";
const { Option } = Select; const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } }; const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
@@ -69,17 +71,27 @@ NameInput.propTypes = {
function EditParameterSettingsDialog(props) { function EditParameterSettingsDialog(props) {
const [param, setParam] = useState(clone(props.parameter)); const [param, setParam] = useState(clone(props.parameter));
const [isNameValid, setIsNameValid] = useState(true); const [isNameValid, setIsNameValid] = useState(true);
const [initialQuery, setInitialQuery] = useState(); const [paramQuery, setParamQuery] = useState();
const mappingParameters = useMemo(
() =>
map(paramQuery && paramQuery.getParametersDefs(), mappingParam => ({
mappingParam,
existingMapping: get(param.parameterMapping, mappingParam.name, {
mappingType: QueryBasedParameterMappingType.UNDEFINED,
}),
})),
[param.parameterMapping, paramQuery]
);
const isNew = !props.parameter.name; const isNew = !props.parameter.name;
// fetch query by id // fetch query by id
const initialQueryId = useRef(props.parameter.queryId);
useEffect(() => { useEffect(() => {
const queryId = props.parameter.queryId; if (initialQueryId.current) {
if (queryId) { Query.get({ id: initialQueryId.current }).then(setParamQuery);
Query.get({ id: queryId }).then(setInitialQuery);
} }
}, [props.parameter.queryId]); }, []);
function isFulfilled() { function isFulfilled() {
// name // name
@@ -93,10 +105,16 @@ function EditParameterSettingsDialog(props) {
} }
// query // query
if (param.type === "query" && !param.queryId) { if (param.type === "query") {
if (!param.queryId) {
return false; return false;
} }
if (find(mappingParameters, { existingMapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED } })) {
return false;
}
}
return true; return true;
} }
@@ -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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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

View File

@@ -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) {

View File

@@ -17,6 +17,7 @@ import ParameterValueInput from "@/components/ParameterValueInput";
import { ParameterMappingType } from "@/services/widget"; import { ParameterMappingType } from "@/services/widget";
import { Parameter, cloneParameter } from "@/services/parameters"; import { Parameter, cloneParameter } from "@/services/parameters";
import HelpTrigger from "@/components/HelpTrigger"; import HelpTrigger from "@/components/HelpTrigger";
import InputPopover from "@/components/InputPopover";
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled"; import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
import EditOutlinedIcon from "@ant-design/icons/EditOutlined"; import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
@@ -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>
); );
} }
} }

View File

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

View File

@@ -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>
); );
} }

View File

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

View File

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

View File

@@ -1,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>
); );
} }

View 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;

View File

@@ -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,
}; };

View File

@@ -0,0 +1,7 @@
.user-groups {
margin: -5px 0 0 -5px;
.ant-tag {
margin: 5px 0 0 5px;
}
}

View File

@@ -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}

View File

@@ -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;
} }
} }

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -34,3 +34,9 @@
font-size: 11px; font-size: 11px;
} }
} }
.dynamic-icon {
display: flex !important;
align-items: center;
justify-content: center;
}

View 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;

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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&apos;s get started</h4> <h4>Let&apos;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,

View File

@@ -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;
}
}
}

View File

@@ -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}
/> />
); );

View File

@@ -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]
);
}

View File

@@ -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>

View File

@@ -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}`}>

View File

@@ -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>
); );
}) })

View File

@@ -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",

View File

@@ -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)) {

View File

@@ -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(() => {

View File

@@ -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>

View 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;
}

View 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);
});
});
});

View 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);
}

View File

@@ -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) {

View File

@@ -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" />

View File

@@ -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

View File

@@ -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" />

View File

@@ -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

View File

@@ -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,

View File

@@ -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} />

View File

@@ -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,
}; };

View File

@@ -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>
); );
} }
} }

View File

@@ -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(

View 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 };
}

View File

@@ -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 />

View 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>
);
}

View File

@@ -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,

View File

@@ -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>
); );
} }
} }

View File

@@ -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={

View File

@@ -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">

View File

@@ -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,

View File

@@ -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>
); );

View 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);

View File

@@ -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);

View 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,
};

View File

@@ -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,
}; };

View File

@@ -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];

View File

@@ -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]);
}

View File

@@ -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]
); );
} }

View File

@@ -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"),

View File

@@ -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);

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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,
}; };

View 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 };
}

View File

@@ -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 }) {

View File

@@ -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) {

View File

@@ -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 = {};

View 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();
});
});
});

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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);
} }

View File

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

View File

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

View File

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

View File

@@ -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;
}
} }

View File

@@ -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