mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
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
This commit is contained in:
11
Dockerfile
11
Dockerfile
@@ -6,9 +6,12 @@ ARG skip_frontend_build
|
|||||||
ENV CYPRESS_INSTALL_BINARY=0
|
ENV CYPRESS_INSTALL_BINARY=0
|
||||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
|
||||||
|
|
||||||
|
RUN useradd -m -d /frontend redash
|
||||||
|
USER redash
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
COPY package.json package-lock.json /frontend/
|
COPY --chown=redash package.json package-lock.json /frontend/
|
||||||
COPY viz-lib /frontend/viz-lib
|
COPY --chown=redash viz-lib /frontend/viz-lib
|
||||||
|
|
||||||
# Controls whether to instrument code for coverage information
|
# Controls whether to instrument code for coverage information
|
||||||
ARG code_coverage
|
ARG code_coverage
|
||||||
@@ -16,8 +19,8 @@ ENV BABEL_ENV=${code_coverage:+test}
|
|||||||
|
|
||||||
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi
|
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi
|
||||||
|
|
||||||
COPY client /frontend/client
|
COPY --chown=redash client /frontend/client
|
||||||
COPY webpack.config.js /frontend/
|
COPY --chown=redash webpack.config.js /frontend/
|
||||||
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
|
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
|
||||||
FROM python:3.7-slim
|
FROM python:3.7-slim
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,16 @@ module.exports = {
|
|||||||
"no-restricted-imports": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
name: "antd",
|
paths: [
|
||||||
message: "Please use antd/lib instead.",
|
{
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -141,6 +141,7 @@ a.label-tag {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.query-fullscreen {
|
.query-fullscreen {
|
||||||
|
|||||||
@@ -13,19 +13,21 @@ export default function ApplicationLayout({ children }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<div className="application-layout-side-menu">
|
<DynamicComponent name="ApplicationWrapper">
|
||||||
<DynamicComponent name="ApplicationDesktopNavbar">
|
<div className="application-layout-side-menu">
|
||||||
<DesktopNavbar />
|
<DynamicComponent name="ApplicationDesktopNavbar">
|
||||||
</DynamicComponent>
|
<DesktopNavbar />
|
||||||
</div>
|
|
||||||
<div className="application-layout-content">
|
|
||||||
<nav className="application-layout-top-menu" ref={mobileNavbarContainerRef}>
|
|
||||||
<DynamicComponent name="ApplicationMobileNavbar" getPopupContainer={getMobileNavbarPopupContainer}>
|
|
||||||
<MobileNavbar getPopupContainer={getMobileNavbarPopupContainer} />
|
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
</nav>
|
</div>
|
||||||
{children}
|
<div className="application-layout-content">
|
||||||
</div>
|
<nav className="application-layout-top-menu" ref={mobileNavbarContainerRef}>
|
||||||
|
<DynamicComponent name="ApplicationMobileNavbar" getPopupContainer={getMobileNavbarPopupContainer}>
|
||||||
|
<MobileNavbar getPopupContainer={getMobileNavbarPopupContainer} />
|
||||||
|
</DynamicComponent>
|
||||||
|
</nav>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</DynamicComponent>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function ErrorMessage({ error, message }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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" />
|
||||||
|
|||||||
@@ -133,10 +133,14 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
|||||||
return helpTriggerType ? helpTriggerType[0] : this.props.href;
|
return helpTriggerType ? helpTriggerType[0] : this.props.href;
|
||||||
};
|
};
|
||||||
|
|
||||||
openDrawer = () => {
|
openDrawer = e => {
|
||||||
this.setState({ visible: true });
|
// keep "open in new tab" behavior
|
||||||
// wait for drawer animation to complete so there's no animation jank
|
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||||
setTimeout(() => this.loadIframe(this.getUrl()), 300);
|
e.preventDefault();
|
||||||
|
this.setState({ visible: true });
|
||||||
|
// wait for drawer animation to complete so there's no animation jank
|
||||||
|
setTimeout(() => this.loadIframe(this.getUrl()), 300);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
closeDrawer = event => {
|
closeDrawer = event => {
|
||||||
@@ -170,15 +174,14 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
|||||||
</>
|
</>
|
||||||
) : null
|
) : null
|
||||||
}>
|
}>
|
||||||
{shouldRenderAsLink ? (
|
<Link
|
||||||
<Link href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank">
|
href={url || this.getUrl()}
|
||||||
{this.props.children}
|
className={className}
|
||||||
</Link>
|
rel="noopener noreferrer"
|
||||||
) : (
|
target="_blank"
|
||||||
<a onClick={this.openDrawer} className={className}>
|
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}>
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</a>
|
</Link>
|
||||||
)}
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Drawer
|
<Drawer
|
||||||
placement="right"
|
placement="right"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import PropTypes from "prop-types";
|
|||||||
import Tag from "antd/lib/tag";
|
import Tag from "antd/lib/tag";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
|
|
||||||
export default function UserGroups({ groups, ...props }) {
|
import "./UserGroups.less";
|
||||||
|
|
||||||
|
export default function UserGroups({ groups, linkGroups, ...props }) {
|
||||||
return (
|
return (
|
||||||
<div {...props}>
|
<div className="user-groups" {...props}>
|
||||||
{map(groups, group => (
|
{map(groups, group => (
|
||||||
<Tag className="m-b-5 m-r-5" key={group.id}>
|
<Tag key={group.id}>{linkGroups ? <Link href={`groups/${group.id}`}>{group.name}</Link> : group.name}</Tag>
|
||||||
<Link href={`groups/${group.id}`}>{group.name}</Link>
|
|
||||||
</Tag>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -19,12 +19,14 @@ export default function UserGroups({ groups, ...props }) {
|
|||||||
UserGroups.propTypes = {
|
UserGroups.propTypes = {
|
||||||
groups: PropTypes.arrayOf(
|
groups: PropTypes.arrayOf(
|
||||||
PropTypes.shape({
|
PropTypes.shape({
|
||||||
id: PropTypes.number.isRequired,
|
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
linkGroups: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
UserGroups.defaultProps = {
|
UserGroups.defaultProps = {
|
||||||
groups: [],
|
groups: [],
|
||||||
|
linkGroups: true,
|
||||||
};
|
};
|
||||||
7
client/app/components/UserGroups.less
Normal file
7
client/app/components/UserGroups.less
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.user-groups {
|
||||||
|
margin: -5px 0 0 -5px;
|
||||||
|
|
||||||
|
.ant-tag {
|
||||||
|
margin: 5px 0 0 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,3 +34,9 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dynamic-icon {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class DynamicDateRangePicker extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { type, value, onSelect, className, dynamicButtonOptions, dateRangeOptions } = this.props;
|
const { type, value, onSelect, className, dynamicButtonOptions, dateRangeOptions, parameter, ...rest } = this.props;
|
||||||
const isDateTimeRange = includes(type, "datetime-range");
|
const isDateTimeRange = includes(type, "datetime-range");
|
||||||
const hasDynamicValue = isDynamicDateRange(value);
|
const hasDynamicValue = isDynamicDateRange(value);
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ class DynamicDateRangePicker extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames("date-range-parameter", className)}>
|
<div {...rest} className={classNames("date-range-parameter", className)}>
|
||||||
<DateRangeComponent
|
<DateRangeComponent
|
||||||
{...dateRangeOptions}
|
{...dateRangeOptions}
|
||||||
ref={this.dateRangeComponentRef}
|
ref={this.dateRangeComponentRef}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-picker-separator {
|
.ant-picker-separator,
|
||||||
|
.ant-picker-range-separator {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface EmptyStateProps<K = unknown> {
|
|||||||
illustration: string;
|
illustration: string;
|
||||||
illustrationPath?: string;
|
illustrationPath?: string;
|
||||||
helpMessage?: React.ReactNode;
|
helpMessage?: React.ReactNode;
|
||||||
|
closable?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
|
||||||
onboardingMode?: boolean;
|
onboardingMode?: boolean;
|
||||||
showAlertStep?: boolean;
|
showAlertStep?: boolean;
|
||||||
@@ -39,8 +41,9 @@ export interface StepProps {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
url?: string;
|
url?: string;
|
||||||
urlText?: string;
|
urlTarget?: string;
|
||||||
text: string;
|
urlText?: React.ReactNode;
|
||||||
|
text?: React.ReactNode;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 HelpTrigger from "@/components/HelpTrigger";
|
||||||
@@ -9,14 +10,14 @@ 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}
|
||||||
@@ -27,15 +28,18 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,6 +64,8 @@ function EmptyState({
|
|||||||
description,
|
description,
|
||||||
illustration,
|
illustration,
|
||||||
helpMessage,
|
helpMessage,
|
||||||
|
closable,
|
||||||
|
onClose,
|
||||||
onboardingMode,
|
onboardingMode,
|
||||||
showAlertStep,
|
showAlertStep,
|
||||||
showDashboardStep,
|
showDashboardStep,
|
||||||
@@ -103,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"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -132,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"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -145,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"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -158,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"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -171,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"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -182,20 +183,27 @@ 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 bg-white tiled">
|
<div className="empty-state-wrapper">
|
||||||
<div className="empty-state__summary">
|
<div className="empty-state bg-white tiled">
|
||||||
{header && <h4>{header}</h4>}
|
<div className="empty-state__summary">
|
||||||
<h2>
|
{header && <h4>{header}</h4>}
|
||||||
<i className={icon} />
|
<h2>
|
||||||
</h2>
|
<i className={icon} />
|
||||||
<p>{description}</p>
|
</h2>
|
||||||
<img src={imageSource} alt={illustration + " Illustration"} width="75%" />
|
<p>{description}</p>
|
||||||
</div>
|
<img src={imageSource} alt={illustration + " Illustration"} width="75%" />
|
||||||
<div className="empty-state__steps">
|
</div>
|
||||||
<h4>Let's get started</h4>
|
<div className="empty-state__steps">
|
||||||
<ol>{stepsItems.map(item => item.node)}</ol>
|
<h4>Let's get started</h4>
|
||||||
{helpMessage}
|
<ol>{stepsItems.map(item => item.node)}</ol>
|
||||||
|
{helpMessage}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{closable && (
|
||||||
|
<a className="close-button" onClick={onClose}>
|
||||||
|
<CloseOutlinedIcon />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -207,6 +215,8 @@ EmptyState.propTypes = {
|
|||||||
illustration: PropTypes.string.isRequired,
|
illustration: PropTypes.string.isRequired,
|
||||||
illustrationPath: PropTypes.string,
|
illustrationPath: PropTypes.string,
|
||||||
helpMessage: PropTypes.node,
|
helpMessage: PropTypes.node,
|
||||||
|
closable: PropTypes.bool,
|
||||||
|
onClose: PropTypes.func,
|
||||||
|
|
||||||
onboardingMode: PropTypes.bool,
|
onboardingMode: PropTypes.bool,
|
||||||
showAlertStep: PropTypes.bool,
|
showAlertStep: PropTypes.bool,
|
||||||
@@ -221,6 +231,8 @@ EmptyState.defaultProps = {
|
|||||||
icon: null,
|
icon: null,
|
||||||
header: null,
|
header: null,
|
||||||
helpMessage: null,
|
helpMessage: null,
|
||||||
|
closable: false,
|
||||||
|
onClose: () => {},
|
||||||
|
|
||||||
onboardingMode: false,
|
onboardingMode: false,
|
||||||
showAlertStep: false,
|
showAlertStep: false,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import (reference, less) "~@/assets/less/ant";
|
||||||
|
|
||||||
// Empty states
|
// Empty states
|
||||||
.empty-state {
|
.empty-state {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -70,3 +72,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// close button
|
||||||
|
.empty-state-wrapper {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 25px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color @animation-duration-slow;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @text-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ export const Columns = {
|
|||||||
overrides
|
overrides
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
timeAgo(overrides) {
|
timeAgo(overrides, timeAgoCustomProps = undefined) {
|
||||||
return extend(
|
return extend(
|
||||||
{
|
{
|
||||||
render: value => <TimeAgo date={value} />,
|
render: value => <TimeAgo date={value} {...timeAgoCustomProps} />,
|
||||||
},
|
},
|
||||||
overrides
|
overrides
|
||||||
);
|
);
|
||||||
@@ -110,6 +110,8 @@ export default class ItemsTable extends React.Component {
|
|||||||
orderByField: PropTypes.string,
|
orderByField: PropTypes.string,
|
||||||
orderByReverse: PropTypes.bool,
|
orderByReverse: PropTypes.bool,
|
||||||
toggleSorting: PropTypes.func,
|
toggleSorting: PropTypes.func,
|
||||||
|
"data-test": PropTypes.string,
|
||||||
|
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@@ -151,6 +153,17 @@ export default class ItemsTable extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRowKey = record => {
|
||||||
|
const { rowKey } = this.props;
|
||||||
|
if (rowKey) {
|
||||||
|
if (isFunction(rowKey)) {
|
||||||
|
return rowKey(record.item);
|
||||||
|
}
|
||||||
|
return record.item[rowKey];
|
||||||
|
}
|
||||||
|
return record.key;
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const tableDataProps = {
|
const tableDataProps = {
|
||||||
columns: this.prepareColumns(),
|
columns: this.prepareColumns(),
|
||||||
@@ -184,9 +197,10 @@ export default class ItemsTable extends React.Component {
|
|||||||
<Table
|
<Table
|
||||||
className={classNames("table-data", { "ant-table-headerless": !showHeader })}
|
className={classNames("table-data", { "ant-table-headerless": !showHeader })}
|
||||||
showHeader={showHeader}
|
showHeader={showHeader}
|
||||||
rowKey={row => row.key}
|
rowKey={this.getRowKey}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
onRow={onTableRow}
|
onRow={onTableRow}
|
||||||
|
data-test={this.props["data-test"]}
|
||||||
{...tableDataProps}
|
{...tableDataProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
<i
|
|
||||||
className="fa fa-angle-double-right copy-to-editor"
|
<Tooltip title="Insert table name into query text" mouseEnterDelay={0} mouseLeaveDelay={0}>
|
||||||
aria-hidden="true"
|
<i
|
||||||
onClick={e => handleSelect(e, item.name)}
|
className="fa fa-angle-double-right copy-to-editor"
|
||||||
/>
|
aria-hidden="true"
|
||||||
|
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>}
|
||||||
<i
|
<Tooltip title="Insert column name into query text" mouseEnterDelay={0} mouseLeaveDelay={0}>
|
||||||
className="fa fa-angle-double-right copy-to-editor"
|
<i
|
||||||
aria-hidden="true"
|
className="fa fa-angle-double-right copy-to-editor"
|
||||||
onClick={e => handleSelect(e, columnName)}
|
aria-hidden="true"
|
||||||
/>
|
onClick={e => handleSelect(e, columnName)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -67,10 +67,6 @@ export default function DatabricksSchemaBrowser({
|
|||||||
setExpandedFlags({});
|
setExpandedFlags({});
|
||||||
}, [currentDatabaseName]);
|
}, [currentDatabaseName]);
|
||||||
|
|
||||||
if (schema.length === 0 && databases.length === 0 && !(loadingDatabases || loadingSchema)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTable(tableName) {
|
function toggleTable(tableName) {
|
||||||
const table = find(schema, { name: tableName });
|
const table = find(schema, { name: tableName });
|
||||||
if (!expandedFlags[tableName] && get(table, "loading", false)) {
|
if (!expandedFlags[tableName] && get(table, "loading", false)) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { has, get, map, first, isFunction, isEmpty } from "lodash";
|
import { includes, has, get, map, first, isFunction, isEmpty, startsWith } from "lodash";
|
||||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||||
import notification from "@/services/notification";
|
import notification from "@/services/notification";
|
||||||
import DatabricksDataSource from "@/services/databricks-data-source";
|
import DatabricksDataSource from "@/services/databricks-data-source";
|
||||||
@@ -25,6 +25,21 @@ function getSchema(dataSource, databaseName, refresh = false) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
|||||||
56
client/app/lib/queryFormat.test.js
Normal file
56
client/app/lib/queryFormat.test.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Query } from "@/services/query";
|
||||||
|
import * as queryFormat from "./queryFormat";
|
||||||
|
|
||||||
|
describe("QueryFormat.formatQuery", () => {
|
||||||
|
test("returns same query text when syntax is not supported", () => {
|
||||||
|
const unsupportedSyntax = "unsupported-syntax";
|
||||||
|
const queryText = "select * from example";
|
||||||
|
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(unsupportedSyntax);
|
||||||
|
const formattedQuery = queryFormat.formatQuery(queryText, unsupportedSyntax);
|
||||||
|
|
||||||
|
expect(isFormatQueryAvailable).toBeFalsy();
|
||||||
|
expect(formattedQuery).toBe(queryText);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sql", () => {
|
||||||
|
const syntax = "sql";
|
||||||
|
|
||||||
|
test("returns the formatted query text", () => {
|
||||||
|
const queryText = "select column1, column2 from example where column1 = 2";
|
||||||
|
const expectedFormattedQueryText = [
|
||||||
|
"select",
|
||||||
|
" column1,",
|
||||||
|
" column2",
|
||||||
|
"from",
|
||||||
|
" example",
|
||||||
|
"where",
|
||||||
|
" column1 = 2",
|
||||||
|
].join("\n");
|
||||||
|
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(syntax);
|
||||||
|
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
|
||||||
|
expect(isFormatQueryAvailable).toBeTruthy();
|
||||||
|
expect(formattedQueryText).toBe(expectedFormattedQueryText);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still recognizes parameters after formatting", () => {
|
||||||
|
const queryText = "select {{param1}}, {{ param2 }}, {{ date-range.start }} from example";
|
||||||
|
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
|
||||||
|
const queryParameters = new Query({ query: queryText }).getParameters().parseQuery();
|
||||||
|
const formattedQueryParameters = new Query({ query: formattedQueryText }).getParameters().parseQuery();
|
||||||
|
expect(formattedQueryParameters.sort()).toEqual(queryParameters.sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("json", () => {
|
||||||
|
const syntax = "json";
|
||||||
|
|
||||||
|
test("returns the formatted query text", () => {
|
||||||
|
const queryText = '{"collection": "example","limit": 10}';
|
||||||
|
const expectedFormattedQueryText = '{\n "collection": "example",\n "limit": 10\n}';
|
||||||
|
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(syntax);
|
||||||
|
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
|
||||||
|
expect(isFormatQueryAvailable).toBeTruthy();
|
||||||
|
expect(formattedQueryText).toBe(expectedFormattedQueryText);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
23
client/app/lib/queryFormat.ts
Normal file
23
client/app/lib/queryFormat.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { trim } from "lodash";
|
||||||
|
import sqlFormatter from "sql-formatter";
|
||||||
|
|
||||||
|
interface QueryFormatterMap {
|
||||||
|
[syntax: string]: (queryText: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueryFormatters: QueryFormatterMap = {
|
||||||
|
sql: queryText => sqlFormatter.format(trim(queryText)),
|
||||||
|
json: queryText => JSON.stringify(JSON.parse(queryText), null, 4),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isFormatQueryAvailable(syntax: string) {
|
||||||
|
return syntax in QueryFormatters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatQuery(queryText: string, syntax: string) {
|
||||||
|
if (!isFormatQueryAvailable(syntax)) {
|
||||||
|
return queryText;
|
||||||
|
}
|
||||||
|
const formatter = QueryFormatters[syntax];
|
||||||
|
return formatter(queryText);
|
||||||
|
}
|
||||||
@@ -127,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) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from "react";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { Alert as AlertType } from "@/components/proptypes";
|
import { Alert as AlertType } from "@/components/proptypes";
|
||||||
|
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
@@ -52,6 +53,7 @@ export default class AlertEdit extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title name={name} alert={alert} onChange={onNameChange} editMode>
|
<Title name={name} alert={alert} onChange={onNameChange} editMode>
|
||||||
|
<DynamicComponent name="AlertEdit.HeaderExtra" alert={alert} />
|
||||||
<Button className="m-r-5" onClick={() => this.cancel()}>
|
<Button className="m-r-5" onClick={() => this.cancel()}>
|
||||||
<i className="fa fa-times m-r-5" />
|
<i className="fa fa-times m-r-5" />
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import Query from "./components/Query";
|
|||||||
import AlertDestinations from "./components/AlertDestinations";
|
import AlertDestinations from "./components/AlertDestinations";
|
||||||
import HorizontalFormItem from "./components/HorizontalFormItem";
|
import HorizontalFormItem from "./components/HorizontalFormItem";
|
||||||
import { STATE_CLASS } from "../alerts/AlertsList";
|
import { STATE_CLASS } from "../alerts/AlertsList";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
|
||||||
function AlertState({ state, lastTriggered }) {
|
function AlertState({ state, lastTriggered }) {
|
||||||
return (
|
return (
|
||||||
@@ -66,6 +67,7 @@ export default class AlertView extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title name={name} alert={alert}>
|
<Title name={name} alert={alert}>
|
||||||
|
<DynamicComponent name="AlertView.HeaderExtra" alert={alert} />
|
||||||
<Tooltip title={canEdit ? "" : "You do not have sufficient permissions to edit this alert"}>
|
<Tooltip title={canEdit ? "" : "You do not have sufficient permissions to edit this alert"}>
|
||||||
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
|
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
|
||||||
<i className="fa fa-edit m-r-5" />
|
<i className="fa fa-edit m-r-5" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,7 +40,7 @@ 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 &&
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
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, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
|
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
@@ -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,91 +65,6 @@ function EmailNotVerifiedAlert() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 };
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
recordEvent("view", "page", "personal_homepage");
|
recordEvent("view", "page", "personal_homepage");
|
||||||
|
|||||||
94
client/app/pages/home/components/FavoritesList.jsx
Normal file
94
client/app/pages/home/components/FavoritesList.jsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { isEmpty } from "lodash";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
import Link from "@/components/Link";
|
||||||
|
import LoadingOutlinedIcon from "@ant-design/icons/LoadingOutlined";
|
||||||
|
|
||||||
|
import { Dashboard } from "@/services/dashboard";
|
||||||
|
import { Query } from "@/services/query";
|
||||||
|
|
||||||
|
export function FavoriteList({ title, resource, itemUrl, emptyState }) {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
resource
|
||||||
|
.favorites()
|
||||||
|
.then(({ results }) => setItems(results))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [resource]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="d-flex align-items-center m-b-20">
|
||||||
|
<p className="flex-fill f-500 c-black m-0">{title}</p>
|
||||||
|
{loading && <LoadingOutlinedIcon />}
|
||||||
|
</div>
|
||||||
|
{!isEmpty(items) && (
|
||||||
|
<div className="list-group">
|
||||||
|
{items.map(item => (
|
||||||
|
<Link key={itemUrl(item)} className="list-group-item" href={itemUrl(item)}>
|
||||||
|
<span className="btn-favourite m-r-5">
|
||||||
|
<i className="fa fa-star" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
{item.name}
|
||||||
|
{item.is_draft && <span className="label label-default m-l-5">Unpublished</span>}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isEmpty(items) && !loading && emptyState}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FavoriteList.propTypes = {
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
resource: PropTypes.func.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
|
itemUrl: PropTypes.func.isRequired,
|
||||||
|
emptyState: PropTypes.node,
|
||||||
|
};
|
||||||
|
FavoriteList.defaultProps = { emptyState: null };
|
||||||
|
|
||||||
|
export function DashboardAndQueryFavoritesList() {
|
||||||
|
return (
|
||||||
|
<div className="tile">
|
||||||
|
<div className="t-body tb-padding">
|
||||||
|
<div className="row home-favorites-list">
|
||||||
|
<div className="col-sm-6 m-t-20">
|
||||||
|
<FavoriteList
|
||||||
|
title="Favorite Dashboards"
|
||||||
|
resource={Dashboard}
|
||||||
|
itemUrl={dashboard => dashboard.url}
|
||||||
|
emptyState={
|
||||||
|
<p>
|
||||||
|
<span className="btn-favourite m-r-5">
|
||||||
|
<i className="fa fa-star" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
Favorite <Link href="dashboards">Dashboards</Link> will appear here
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 m-t-20">
|
||||||
|
<FavoriteList
|
||||||
|
title="Favorite Queries"
|
||||||
|
resource={Query}
|
||||||
|
itemUrl={query => `queries/${query.id}`}
|
||||||
|
emptyState={
|
||||||
|
<p>
|
||||||
|
<span className="btn-favourite m-r-5">
|
||||||
|
<i className="fa fa-star" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
Favorite <Link href="queries">Queries</Link> will appear here
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,10 +9,12 @@ import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSess
|
|||||||
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 recordEvent from "@/services/recordEvent";
|
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
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,7 +39,6 @@ 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";
|
||||||
@@ -95,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 => {
|
||||||
@@ -191,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>
|
||||||
@@ -265,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={
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import FullscreenOutlinedIcon from "@ant-design/icons/FullscreenOutlined";
|
|||||||
import FullscreenExitOutlinedIcon from "@ant-design/icons/FullscreenExitOutlined";
|
import FullscreenExitOutlinedIcon from "@ant-design/icons/FullscreenExitOutlined";
|
||||||
|
|
||||||
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
|
||||||
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";
|
||||||
|
|
||||||
@@ -104,14 +105,16 @@ function QueryView(props) {
|
|||||||
selectedVisualization={selectedVisualization}
|
selectedVisualization={selectedVisualization}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
<DynamicComponent name="QueryView.HeaderExtra" query={query}>
|
<DynamicComponent name="QueryView.HeaderExtra" query={query}>
|
||||||
<QueryViewButton
|
{policy.canRun(query) && (
|
||||||
className="m-r-5"
|
<QueryViewButton
|
||||||
type="primary"
|
className="m-r-5"
|
||||||
shortcut="mod+enter, alt+enter, ctrl+enter"
|
type="primary"
|
||||||
disabled={!queryFlags.canExecute || isExecuting || areParametersDirty}
|
shortcut="mod+enter, alt+enter, ctrl+enter"
|
||||||
onClick={doExecuteQuery}>
|
disabled={!queryFlags.canExecute || isExecuting || areParametersDirty}
|
||||||
Refresh
|
onClick={doExecuteQuery}>
|
||||||
</QueryViewButton>
|
Refresh
|
||||||
|
</QueryViewButton>
|
||||||
|
)}
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
}
|
}
|
||||||
tagsExtra={
|
tagsExtra={
|
||||||
@@ -168,15 +171,18 @@ function QueryView(props) {
|
|||||||
onAddVisualization={addVisualization}
|
onAddVisualization={addVisualization}
|
||||||
onDeleteVisualization={deleteVisualization}
|
onDeleteVisualization={deleteVisualization}
|
||||||
refreshButton={
|
refreshButton={
|
||||||
<Button
|
policy.canRun(query) && (
|
||||||
type="primary"
|
<Button
|
||||||
disabled={!queryFlags.canExecute || areParametersDirty}
|
type="primary"
|
||||||
loading={isExecuting}
|
disabled={!queryFlags.canExecute || areParametersDirty}
|
||||||
onClick={doExecuteQuery}>
|
loading={isExecuting}
|
||||||
{!isExecuting && <i className="zmdi zmdi-refresh m-r-5" aria-hidden="true" />}
|
onClick={doExecuteQuery}>
|
||||||
Refresh Now
|
{!isExecuting && <i className="zmdi zmdi-refresh m-r-5" aria-hidden="true" />}
|
||||||
</Button>
|
Refresh Now
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
canRefresh={policy.canRun(query)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="query-results-footer">
|
<div className="query-results-footer">
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export default function QueryPageHeader({
|
|||||||
onClick: publishQuery,
|
onClick: publishQuery,
|
||||||
},
|
},
|
||||||
unpublish: {
|
unpublish: {
|
||||||
isAvailable: !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isDraft,
|
isAvailable: !clientConfig.disablePublish && !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isDraft,
|
||||||
title: "Unpublish",
|
title: "Unpublish",
|
||||||
onClick: unpublishQuery,
|
onClick: unpublishQuery,
|
||||||
},
|
},
|
||||||
@@ -179,7 +179,7 @@ export default function QueryPageHeader({
|
|||||||
|
|
||||||
{!queryFlags.isNew && queryFlags.canViewSource && (
|
{!queryFlags.isNew && queryFlags.canViewSource && (
|
||||||
<span>
|
<span>
|
||||||
{!sourceMode && (
|
{!sourceMode && queryFlags.canEdit && (
|
||||||
<Link.Button className="m-r-5" href={query.getUrl(true, selectedVisualization)}>
|
<Link.Button className="m-r-5" href={query.getUrl(true, selectedVisualization)}>
|
||||||
<i className="fa fa-pencil-square-o" aria-hidden="true" />
|
<i className="fa fa-pencil-square-o" aria-hidden="true" />
|
||||||
<span className="m-l-5">Edit Source</span>
|
<span className="m-l-5">Edit Source</span>
|
||||||
@@ -211,7 +211,7 @@ export default function QueryPageHeader({
|
|||||||
|
|
||||||
QueryPageHeader.propTypes = {
|
QueryPageHeader.propTypes = {
|
||||||
query: PropTypes.shape({
|
query: PropTypes.shape({
|
||||||
id: PropTypes.number,
|
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
tags: PropTypes.arrayOf(PropTypes.string),
|
tags: PropTypes.arrayOf(PropTypes.string),
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Card from "antd/lib/card";
|
|||||||
import WarningFilledIcon from "@ant-design/icons/WarningFilled";
|
import WarningFilledIcon from "@ant-design/icons/WarningFilled";
|
||||||
import Typography from "antd/lib/typography";
|
import Typography from "antd/lib/typography";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { currentUser } from "@/services/auth";
|
import { currentUser } from "@/services/auth";
|
||||||
|
|
||||||
import useQueryFlags from "../hooks/useQueryFlags";
|
import useQueryFlags from "../hooks/useQueryFlags";
|
||||||
@@ -69,10 +70,12 @@ export default function QuerySourceAlerts({ query, dataSourcesAvailable }) {
|
|||||||
return (
|
return (
|
||||||
<div className="query-source-alerts">
|
<div className="query-source-alerts">
|
||||||
<Card>
|
<Card>
|
||||||
<div className="query-source-alerts-icon">
|
<DynamicComponent name="QuerySource.Alerts" query={query} dataSourcesAvailable={dataSourcesAvailable}>
|
||||||
<WarningFilledIcon />
|
<div className="query-source-alerts-icon">
|
||||||
</div>
|
<WarningFilledIcon />
|
||||||
{message}
|
</div>
|
||||||
|
{message}
|
||||||
|
</DynamicComponent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export default function QueryVisualizationTabs({
|
|||||||
onAddVisualization,
|
onAddVisualization,
|
||||||
onDeleteVisualization,
|
onDeleteVisualization,
|
||||||
refreshButton,
|
refreshButton,
|
||||||
|
canRefresh,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
const visualizations = useMemo(
|
const visualizations = useMemo(
|
||||||
@@ -154,7 +155,11 @@ export default function QueryVisualizationTabs({
|
|||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Query has no result"
|
title="Query has no result"
|
||||||
message="Execute/Refresh the query to show results."
|
message={
|
||||||
|
canRefresh
|
||||||
|
? "Execute/Refresh the query to show results."
|
||||||
|
: "You do not have a permission to execute/refresh this query."
|
||||||
|
}
|
||||||
refreshButton={refreshButton}
|
refreshButton={refreshButton}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -174,6 +179,7 @@ QueryVisualizationTabs.propTypes = {
|
|||||||
onAddVisualization: PropTypes.func,
|
onAddVisualization: PropTypes.func,
|
||||||
onDeleteVisualization: PropTypes.func,
|
onDeleteVisualization: PropTypes.func,
|
||||||
refreshButton: PropTypes.node,
|
refreshButton: PropTypes.node,
|
||||||
|
canRefresh: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
QueryVisualizationTabs.defaultProps = {
|
QueryVisualizationTabs.defaultProps = {
|
||||||
@@ -186,4 +192,5 @@ QueryVisualizationTabs.defaultProps = {
|
|||||||
onAddVisualization: () => {},
|
onAddVisualization: () => {},
|
||||||
onDeleteVisualization: () => {},
|
onDeleteVisualization: () => {},
|
||||||
refreshButton: null,
|
refreshButton: null,
|
||||||
|
canRefresh: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ function isAutoLimitAvailable(dataSource) {
|
|||||||
|
|
||||||
export default function useAutoLimitFlags(dataSource, query, setQuery) {
|
export default function useAutoLimitFlags(dataSource, query, setQuery) {
|
||||||
const isAvailable = isAutoLimitAvailable(dataSource);
|
const isAvailable = isAutoLimitAvailable(dataSource);
|
||||||
const [isChecked, setIsChecked] = useState(localOptions.get("applyAutoLimit", true));
|
const [isChecked, setIsChecked] = useState(query.options.apply_auto_limit);
|
||||||
query.options.apply_auto_limit = isAvailable && isChecked;
|
query.options.apply_auto_limit = isChecked;
|
||||||
|
|
||||||
const setAutoLimit = useCallback(
|
const setAutoLimit = useCallback(
|
||||||
state => {
|
state => {
|
||||||
setIsChecked(state);
|
setIsChecked(state);
|
||||||
localOptions.set("applyAutoLimit", state);
|
localOptions.set("applyAutoLimit", state);
|
||||||
setQuery(extend(query.clone(), { options: { ...query.options, apply_auto_limit: isAvailable && state } }));
|
setQuery(extend(query.clone(), { options: { ...query.options, apply_auto_limit: state } }));
|
||||||
},
|
},
|
||||||
[query, setQuery, isAvailable]
|
[query, setQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
return [isAvailable, isChecked, setAutoLimit];
|
return [isAvailable, isChecked, setAutoLimit];
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { extend, get } from "lodash";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { Query } from "@/services/query";
|
|
||||||
import notification from "@/services/notification";
|
|
||||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
|
||||||
|
|
||||||
export default function useFormatQuery(query, syntax, onChange) {
|
|
||||||
const handleChange = useImmutableCallback(onChange);
|
|
||||||
|
|
||||||
return useCallback(() => {
|
|
||||||
Query.format(syntax || "sql", query.query)
|
|
||||||
.then(queryText => {
|
|
||||||
handleChange(extend(query.clone(), { query: queryText }));
|
|
||||||
})
|
|
||||||
.catch(error =>
|
|
||||||
notification.error(get(error, "response.data.message", "Failed to format query: unknown error."))
|
|
||||||
);
|
|
||||||
}, [query, syntax, handleChange]);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isEmpty } from "lodash";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import useUpdateQuery from "./useUpdateQuery";
|
import useUpdateQuery from "./useUpdateQuery";
|
||||||
import navigateTo from "@/components/ApplicationArea/navigateTo";
|
import navigateTo from "@/components/ApplicationArea/navigateTo";
|
||||||
@@ -5,6 +6,7 @@ import navigateTo from "@/components/ApplicationArea/navigateTo";
|
|||||||
export default function useQuery(originalQuery) {
|
export default function useQuery(originalQuery) {
|
||||||
const [query, setQuery] = useState(originalQuery);
|
const [query, setQuery] = useState(originalQuery);
|
||||||
const [originalQuerySource, setOriginalQuerySource] = useState(originalQuery.query);
|
const [originalQuerySource, setOriginalQuerySource] = useState(originalQuery.query);
|
||||||
|
const [originalAutoLimit, setOriginalAutoLimit] = useState(query.options.apply_auto_limit);
|
||||||
|
|
||||||
const updateQuery = useUpdateQuery(query, updatedQuery => {
|
const updateQuery = useUpdateQuery(query, updatedQuery => {
|
||||||
// It's important to update URL first, and only then update state
|
// It's important to update URL first, and only then update state
|
||||||
@@ -14,15 +16,18 @@ export default function useQuery(originalQuery) {
|
|||||||
}
|
}
|
||||||
setQuery(updatedQuery);
|
setQuery(updatedQuery);
|
||||||
setOriginalQuerySource(updatedQuery.query);
|
setOriginalQuerySource(updatedQuery.query);
|
||||||
|
setOriginalAutoLimit(updatedQuery.options.apply_auto_limit);
|
||||||
});
|
});
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
query,
|
query,
|
||||||
setQuery,
|
setQuery,
|
||||||
isDirty: query.query !== originalQuerySource,
|
isDirty:
|
||||||
|
query.query !== originalQuerySource ||
|
||||||
|
(!isEmpty(query.query) && query.options.apply_auto_limit !== originalAutoLimit),
|
||||||
saveQuery: () => updateQuery(),
|
saveQuery: () => updateQuery(),
|
||||||
}),
|
}),
|
||||||
[query, originalQuerySource, updateQuery]
|
[query, originalQuerySource, updateQuery, originalAutoLimit]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isNil, isEmpty } from "lodash";
|
import { isNil, isEmpty } from "lodash";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { currentUser } from "@/services/auth";
|
import { currentUser } from "@/services/auth";
|
||||||
|
import { policy } from "@/services/policy";
|
||||||
|
|
||||||
export default function useQueryFlags(query, dataSource = null) {
|
export default function useQueryFlags(query, dataSource = null) {
|
||||||
dataSource = dataSource || { view_only: true };
|
dataSource = dataSource || { view_only: true };
|
||||||
@@ -15,10 +16,11 @@ export default function useQueryFlags(query, dataSource = null) {
|
|||||||
// permissions flags
|
// permissions flags
|
||||||
canCreate: currentUser.hasPermission("create_query"),
|
canCreate: currentUser.hasPermission("create_query"),
|
||||||
canView: currentUser.hasPermission("view_query"),
|
canView: currentUser.hasPermission("view_query"),
|
||||||
canEdit: currentUser.hasPermission("edit_query") && query.can_edit,
|
canEdit: currentUser.hasPermission("edit_query") && policy.canEdit(query),
|
||||||
canViewSource: currentUser.hasPermission("view_source"),
|
canViewSource: currentUser.hasPermission("view_source"),
|
||||||
canExecute:
|
canExecute:
|
||||||
!isEmpty(query.query) &&
|
!isEmpty(query.query) &&
|
||||||
|
policy.canRun(query) &&
|
||||||
(query.is_safe || (currentUser.hasPermission("execute_query") && !dataSource.view_only)),
|
(query.is_safe || (currentUser.hasPermission("execute_query") && !dataSource.view_only)),
|
||||||
canFork: currentUser.hasPermission("edit_query") && !dataSource.view_only,
|
canFork: currentUser.hasPermission("edit_query") && !dataSource.view_only,
|
||||||
canSchedule: currentUser.hasPermission("schedule_query"),
|
canSchedule: currentUser.hasPermission("schedule_query"),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Modal from "antd/lib/modal";
|
|||||||
import { Query } from "@/services/query";
|
import { Query } from "@/services/query";
|
||||||
import notification from "@/services/notification";
|
import notification from "@/services/notification";
|
||||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
||||||
|
import { policy } from "@/services/policy";
|
||||||
|
|
||||||
class SaveQueryError extends Error {
|
class SaveQueryError extends Error {
|
||||||
constructor(message, detailedMessage = null) {
|
constructor(message, detailedMessage = null) {
|
||||||
@@ -94,10 +95,11 @@ export default function useUpdateQuery(query, onChange) {
|
|||||||
"options",
|
"options",
|
||||||
"latest_query_data_id",
|
"latest_query_data_id",
|
||||||
"is_draft",
|
"is_draft",
|
||||||
|
"tags",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return doSaveQuery(data, { canOverwrite: query.can_edit })
|
return doSaveQuery(data, { canOverwrite: policy.canEdit(query) })
|
||||||
.then(updatedQuery => {
|
.then(updatedQuery => {
|
||||||
if (!isNil(successMessage)) {
|
if (!isNil(successMessage)) {
|
||||||
notification.success(successMessage);
|
notification.success(successMessage);
|
||||||
|
|||||||
@@ -1,89 +1,37 @@
|
|||||||
import { get } from "lodash";
|
import React from "react";
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
|
import Skeleton from "antd/lib/skeleton";
|
||||||
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
||||||
import LoadingState from "@/components/items-list/components/LoadingState";
|
|
||||||
import wrapSettingsTab from "@/components/SettingsWrapper";
|
import wrapSettingsTab from "@/components/SettingsWrapper";
|
||||||
|
|
||||||
import recordEvent from "@/services/recordEvent";
|
|
||||||
import OrgSettings from "@/services/organizationSettings";
|
|
||||||
import routes from "@/services/routes";
|
import routes from "@/services/routes";
|
||||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
|
||||||
import { getHorizontalFormProps, getHorizontalFormItemWithoutLabelProps } from "@/styles/formStyle";
|
import { getHorizontalFormProps, getHorizontalFormItemWithoutLabelProps } from "@/styles/formStyle";
|
||||||
|
|
||||||
|
import useOrganizationSettings from "./hooks/useOrganizationSettings";
|
||||||
import GeneralSettings from "./components/GeneralSettings";
|
import GeneralSettings from "./components/GeneralSettings";
|
||||||
import AuthSettings from "./components/AuthSettings";
|
import AuthSettings from "./components/AuthSettings";
|
||||||
|
|
||||||
function OrganizationSettings({ onError }) {
|
function OrganizationSettings({ onError }) {
|
||||||
const [settings, setSettings] = useState({});
|
const { settings, currentValues, isLoading, isSaving, handleSubmit, handleChange } = useOrganizationSettings(onError);
|
||||||
const [currentValues, setCurrentValues] = useState({});
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
const handleError = useImmutableCallback(onError);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
recordEvent("view", "page", "org_settings");
|
|
||||||
|
|
||||||
let isCancelled = false;
|
|
||||||
|
|
||||||
OrgSettings.get()
|
|
||||||
.then(response => {
|
|
||||||
if (!isCancelled) {
|
|
||||||
const settings = get(response, "settings");
|
|
||||||
setSettings(settings);
|
|
||||||
setCurrentValues({ ...settings });
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
if (!isCancelled) {
|
|
||||||
handleError(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isCancelled = true;
|
|
||||||
};
|
|
||||||
}, [handleError]);
|
|
||||||
|
|
||||||
const handleChange = useCallback(changes => {
|
|
||||||
setCurrentValues(currentValues => ({ ...currentValues, ...changes }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
|
||||||
if (!isSaving) {
|
|
||||||
setIsSaving(true);
|
|
||||||
OrgSettings.save(currentValues)
|
|
||||||
.then(response => {
|
|
||||||
const settings = get(response, "settings");
|
|
||||||
setSettings(settings);
|
|
||||||
setCurrentValues({ ...settings });
|
|
||||||
})
|
|
||||||
.catch(handleError)
|
|
||||||
.finally(() => setIsSaving(false));
|
|
||||||
}
|
|
||||||
}, [isSaving, currentValues, handleError]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row" data-test="OrganizationSettings">
|
<div className="row" data-test="OrganizationSettings">
|
||||||
<div className="m-r-20 m-l-20">
|
<div className="m-r-20 m-l-20">
|
||||||
{isLoading ? (
|
<Form {...getHorizontalFormProps()} onFinish={handleSubmit}>
|
||||||
<LoadingState className="" />
|
<GeneralSettings loading={isLoading} settings={settings} values={currentValues} onChange={handleChange} />
|
||||||
) : (
|
<AuthSettings loading={isLoading} settings={settings} values={currentValues} onChange={handleChange} />
|
||||||
<Form {...getHorizontalFormProps()} onFinish={handleSubmit}>
|
<Form.Item {...getHorizontalFormItemWithoutLabelProps()}>
|
||||||
<GeneralSettings settings={settings} values={currentValues} onChange={handleChange} />
|
{isLoading ? (
|
||||||
<AuthSettings settings={settings} values={currentValues} onChange={handleChange} />
|
<Skeleton.Button active />
|
||||||
<Form.Item {...getHorizontalFormItemWithoutLabelProps()}>
|
) : (
|
||||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
)}
|
||||||
</Form>
|
</Form.Item>
|
||||||
)}
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,19 +3,20 @@ import Alert from "antd/lib/alert";
|
|||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
import Checkbox from "antd/lib/checkbox";
|
import Checkbox from "antd/lib/checkbox";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
|
import Skeleton from "antd/lib/skeleton";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { clientConfig } from "@/services/auth";
|
import { clientConfig } from "@/services/auth";
|
||||||
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
||||||
|
|
||||||
export default function PasswordLoginSettings(props) {
|
export default function PasswordLoginSettings(props) {
|
||||||
const { settings, values, onChange } = props;
|
const { settings, values, onChange, loading } = props;
|
||||||
|
|
||||||
const isTheOnlyAuthMethod =
|
const isTheOnlyAuthMethod =
|
||||||
!clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !values.auth_saml_enabled;
|
!clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !values.auth_saml_enabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicComponent name="OrganizationSettings.PasswordLoginSettings" {...props}>
|
<DynamicComponent name="OrganizationSettings.PasswordLoginSettings" {...props}>
|
||||||
{!settings.auth_password_login_enabled && (
|
{!loading && !settings.auth_password_login_enabled && (
|
||||||
<Alert
|
<Alert
|
||||||
message="Password based login is currently disabled and users will
|
message="Password based login is currently disabled and users will
|
||||||
be able to login only with the enabled SSO options."
|
be able to login only with the enabled SSO options."
|
||||||
@@ -24,18 +25,22 @@ export default function PasswordLoginSettings(props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Form.Item label="Password Login">
|
<Form.Item label="Password Login">
|
||||||
<Checkbox
|
{loading ? (
|
||||||
checked={values.auth_password_login_enabled}
|
<Skeleton title={{ width: 300 }} paragraph={false} active />
|
||||||
disabled={isTheOnlyAuthMethod}
|
) : (
|
||||||
onChange={e => onChange({ auth_password_login_enabled: e.target.checked })}>
|
<Checkbox
|
||||||
<Tooltip
|
checked={values.auth_password_login_enabled}
|
||||||
title={
|
disabled={isTheOnlyAuthMethod}
|
||||||
isTheOnlyAuthMethod ? "Password login can be disabled only if another login method is enabled." : null
|
onChange={e => onChange({ auth_password_login_enabled: e.target.checked })}>
|
||||||
}
|
<Tooltip
|
||||||
placement="right">
|
title={
|
||||||
Password Login Enabled
|
isTheOnlyAuthMethod ? "Password login can be disabled only if another login method is enabled." : null
|
||||||
</Tooltip>
|
}
|
||||||
</Checkbox>
|
placement="right">
|
||||||
|
Password Login Enabled
|
||||||
|
</Tooltip>
|
||||||
|
</Checkbox>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
|
import Skeleton from "antd/lib/skeleton";
|
||||||
import Radio from "antd/lib/radio";
|
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 onChangeEnabledStatus = e => {
|
||||||
const updates = { auth_saml_enabled: !!e.target.value };
|
const updates = { auth_saml_enabled: !!e.target.value };
|
||||||
@@ -20,13 +21,17 @@ export default function SAMLSettings(props) {
|
|||||||
<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">
|
||||||
<Radio.Group
|
{loading ? (
|
||||||
onChange={onChangeEnabledStatus}
|
<Skeleton title={{ width: 300 }} paragraph={false} active />
|
||||||
value={values.auth_saml_enabled && (values.auth_saml_type || "dynamic")}>
|
) : (
|
||||||
<Radio value={false}>Disabled</Radio>
|
<Radio.Group
|
||||||
<Radio value={"static"}>Enabled (Static)</Radio>
|
onChange={onChangeEnabledStatus}
|
||||||
<Radio value={"dynamic"}>Enabled (Dynamic)</Radio>
|
value={values.auth_saml_enabled && (values.auth_saml_type || "dynamic")}>
|
||||||
</Radio.Group>
|
<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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
}>
|
}>
|
||||||
<Checkbox
|
{loading ? (
|
||||||
name="beacon_consent"
|
<Skeleton title={{ width: 300 }} paragraph={false} active />
|
||||||
checked={values.beacon_consent}
|
) : (
|
||||||
onChange={e => onChange({ beacon_consent: e.target.checked })}>
|
<Checkbox
|
||||||
Help Redash improve by automatically sending anonymous usage data
|
name="beacon_consent"
|
||||||
</Checkbox>
|
checked={values.beacon_consent}
|
||||||
|
onChange={e => onChange({ beacon_consent: e.target.checked })}>
|
||||||
|
Help Redash improve by automatically sending anonymous usage data
|
||||||
|
</Checkbox>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,41 +2,52 @@ 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">
|
||||||
<DynamicComponent name="OrganizationSettings.FeatureFlagsSettings.PermissionsControl" {...props}>
|
{loading ? (
|
||||||
<Row>
|
<>
|
||||||
<Checkbox
|
<Row>
|
||||||
name="feature_show_permissions_control"
|
<Skeleton title={false} paragraph={{ width: [300, 300, 300], rows: 3 }} active />
|
||||||
checked={values.feature_show_permissions_control}
|
</Row>
|
||||||
onChange={e => onChange({ feature_show_permissions_control: e.target.checked })}>
|
</>
|
||||||
Enable experimental multiple owners support
|
) : (
|
||||||
</Checkbox>
|
<>
|
||||||
</Row>
|
<DynamicComponent name="OrganizationSettings.FeatureFlagsSettings.PermissionsControl" {...props}>
|
||||||
</DynamicComponent>
|
<Row>
|
||||||
<Row>
|
<Checkbox
|
||||||
<Checkbox
|
name="feature_show_permissions_control"
|
||||||
name="send_email_on_failed_scheduled_queries"
|
checked={values.feature_show_permissions_control}
|
||||||
checked={values.send_email_on_failed_scheduled_queries}
|
onChange={e => onChange({ feature_show_permissions_control: e.target.checked })}>
|
||||||
onChange={e => onChange({ send_email_on_failed_scheduled_queries: e.target.checked })}>
|
Enable experimental multiple owners support
|
||||||
Email query owners when scheduled queries fail
|
</Checkbox>
|
||||||
</Checkbox>
|
</Row>
|
||||||
</Row>
|
</DynamicComponent>
|
||||||
<Row>
|
<Row>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
name="multi_byte_search_enabled"
|
name="send_email_on_failed_scheduled_queries"
|
||||||
checked={values.multi_byte_search_enabled}
|
checked={values.send_email_on_failed_scheduled_queries}
|
||||||
onChange={e => onChange({ multi_byte_search_enabled: e.target.checked })}>
|
onChange={e => onChange({ send_email_on_failed_scheduled_queries: e.target.checked })}>
|
||||||
Enable multi-byte (Chinese, Japanese, and Korean) search for query names and descriptions (slower)
|
Email query owners when scheduled queries fail
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</Row>
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Checkbox
|
||||||
|
name="multi_byte_search_enabled"
|
||||||
|
checked={values.multi_byte_search_enabled}
|
||||||
|
onChange={e => onChange({ multi_byte_search_enabled: e.target.checked })}>
|
||||||
|
Enable multi-byte (Chinese, Japanese, and Korean) search for query names and descriptions (slower)
|
||||||
|
</Checkbox>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,33 +2,42 @@ 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">
|
||||||
<Select
|
{loading ? (
|
||||||
value={values.date_format}
|
<Skeleton.Input style={{ width: 300 }} active />
|
||||||
onChange={value => onChange({ date_format: value })}
|
) : (
|
||||||
data-test="DateFormatSelect">
|
<Select
|
||||||
{clientConfig.dateFormatList.map(dateFormat => (
|
value={values.date_format}
|
||||||
<Select.Option key={dateFormat}>{dateFormat}</Select.Option>
|
onChange={value => onChange({ date_format: value })}
|
||||||
))}
|
data-test="DateFormatSelect">
|
||||||
</Select>
|
{clientConfig.dateFormatList.map(dateFormat => (
|
||||||
|
<Select.Option key={dateFormat}>{dateFormat}</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="Time Format">
|
<Form.Item label="Time Format">
|
||||||
<Select
|
{loading ? (
|
||||||
value={values.time_format}
|
<Skeleton.Input style={{ width: 300 }} active />
|
||||||
onChange={value => onChange({ time_format: value })}
|
) : (
|
||||||
data-test="TimeFormatSelect">
|
<Select
|
||||||
{clientConfig.timeFormatList.map(timeFormat => (
|
value={values.time_format}
|
||||||
<Select.Option key={timeFormat}>{timeFormat}</Select.Option>
|
onChange={value => onChange({ time_format: value })}
|
||||||
))}
|
data-test="TimeFormatSelect">
|
||||||
</Select>
|
{clientConfig.timeFormatList.map(timeFormat => (
|
||||||
|
<Select.Option key={timeFormat}>{timeFormat}</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Checkbox from "antd/lib/checkbox";
|
import Checkbox from "antd/lib/checkbox";
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
|
import Skeleton from "antd/lib/skeleton";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
||||||
|
|
||||||
export default function PlotlySettings(props) {
|
export default function PlotlySettings(props) {
|
||||||
const { values, onChange } = props;
|
const { values, onChange, loading } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicComponent name="OrganizationSettings.PlotlySettings" {...props}>
|
<DynamicComponent name="OrganizationSettings.PlotlySettings" {...props}>
|
||||||
<Form.Item label="Chart Visualization">
|
<Form.Item label="Chart Visualization">
|
||||||
<Checkbox
|
{loading ? (
|
||||||
name="hide_plotly_mode_bar"
|
<Skeleton title={{ width: 300 }} paragraph={false} active />
|
||||||
checked={values.hide_plotly_mode_bar}
|
) : (
|
||||||
onChange={e => onChange({ hide_plotly_mode_bar: e.target.checked })}>
|
<Checkbox
|
||||||
Hide Plotly mode bar
|
name="hide_plotly_mode_bar"
|
||||||
</Checkbox>
|
checked={values.hide_plotly_mode_bar}
|
||||||
|
onChange={e => onChange({ hide_plotly_mode_bar: e.target.checked })}>
|
||||||
|
Hide Plotly mode bar
|
||||||
|
</Checkbox>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ export const SettingsEditorPropTypes = {
|
|||||||
settings: PropTypes.object,
|
settings: PropTypes.object,
|
||||||
values: PropTypes.object,
|
values: PropTypes.object,
|
||||||
onChange: PropTypes.func, // (key, value) => void
|
onChange: PropTypes.func, // (key, value) => void
|
||||||
|
loading: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsEditorDefaultProps = {
|
export const SettingsEditorDefaultProps = {
|
||||||
settings: {},
|
settings: {},
|
||||||
values: {},
|
values: {},
|
||||||
onChange: () => {},
|
onChange: () => {},
|
||||||
|
loading: false,
|
||||||
};
|
};
|
||||||
|
|||||||
59
client/app/pages/settings/hooks/useOrganizationSettings.js
Normal file
59
client/app/pages/settings/hooks/useOrganizationSettings.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { get } from "lodash";
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import recordEvent from "@/services/recordEvent";
|
||||||
|
import OrgSettings from "@/services/organizationSettings";
|
||||||
|
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
||||||
|
|
||||||
|
export default function useOrganizationSettings({ onError }) {
|
||||||
|
const [settings, setSettings] = useState({});
|
||||||
|
const [currentValues, setCurrentValues] = useState({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleError = useImmutableCallback(onError);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
recordEvent("view", "page", "org_settings");
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
OrgSettings.get()
|
||||||
|
.then(response => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
const settings = get(response, "settings");
|
||||||
|
setSettings(settings);
|
||||||
|
setCurrentValues({ ...settings });
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [handleError]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(changes => {
|
||||||
|
setCurrentValues(currentValues => ({ ...currentValues, ...changes }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!isSaving) {
|
||||||
|
setIsSaving(true);
|
||||||
|
OrgSettings.save(currentValues)
|
||||||
|
.then(response => {
|
||||||
|
const settings = get(response, "settings");
|
||||||
|
setSettings(settings);
|
||||||
|
setCurrentValues({ ...settings });
|
||||||
|
})
|
||||||
|
.catch(handleError)
|
||||||
|
.finally(() => setIsSaving(false));
|
||||||
|
}
|
||||||
|
}, [isSaving, currentValues, handleError]);
|
||||||
|
|
||||||
|
return { settings, currentValues, isLoading, isSaving, handleSubmit, handleChange };
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { UserProfile } from "@/components/proptypes";
|
import { UserProfile } from "@/components/proptypes";
|
||||||
|
import UserGroups from "@/components/UserGroups";
|
||||||
|
|
||||||
import UserGroups from "./UserGroups";
|
|
||||||
import useUserGroups from "../hooks/useUserGroups";
|
import useUserGroups from "../hooks/useUserGroups";
|
||||||
|
|
||||||
export default function ReadOnlyUserProfile({ user }) {
|
export default function ReadOnlyUserProfile({ user }) {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import PropTypes from "prop-types";
|
|||||||
import { UserProfile } from "@/components/proptypes";
|
import { UserProfile } from "@/components/proptypes";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
||||||
|
import UserGroups from "@/components/UserGroups";
|
||||||
|
|
||||||
import User from "@/services/user";
|
import User from "@/services/user";
|
||||||
import { currentUser } from "@/services/auth";
|
import { currentUser } from "@/services/auth";
|
||||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
||||||
|
|
||||||
import UserGroups from "./UserGroups";
|
|
||||||
import useUserGroups from "../hooks/useUserGroups";
|
import useUserGroups from "../hooks/useUserGroups";
|
||||||
|
|
||||||
export default function UserInfoForm(props) {
|
export default function UserInfoForm(props) {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isArray } from "lodash";
|
import { get, isArray } from "lodash";
|
||||||
import { currentUser, clientConfig } from "@/services/auth";
|
import { currentUser, clientConfig } from "@/services/auth";
|
||||||
|
|
||||||
/* eslint-disable class-methods-use-this */
|
/* eslint-disable class-methods-use-this */
|
||||||
@@ -57,4 +57,12 @@ export default class DefaultPolicy {
|
|||||||
const result = clientConfig.queryRefreshIntervals;
|
const result = clientConfig.queryRefreshIntervals;
|
||||||
return isArray(result) ? result : null;
|
return isArray(result) ? result : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canEdit(object) {
|
||||||
|
return get(object, "can_edit", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
canRun() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import location from "@/services/location";
|
|||||||
import { Parameter, createParameter } from "./parameters";
|
import { Parameter, createParameter } from "./parameters";
|
||||||
import { currentUser } from "./auth";
|
import { currentUser } from "./auth";
|
||||||
import QueryResult from "./query-result";
|
import QueryResult from "./query-result";
|
||||||
|
import localOptions from "@/lib/localOptions";
|
||||||
|
|
||||||
Mustache.escape = identity; // do not html-escape values
|
Mustache.escape = identity; // do not html-escape values
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export class Query {
|
|||||||
if (!has(this, "options")) {
|
if (!has(this, "options")) {
|
||||||
this.options = {};
|
this.options = {};
|
||||||
}
|
}
|
||||||
|
this.options.apply_auto_limit = !!this.options.apply_auto_limit;
|
||||||
|
|
||||||
if (!isArray(this.options.parameters)) {
|
if (!isArray(this.options.parameters)) {
|
||||||
this.options.parameters = [];
|
this.options.parameters = [];
|
||||||
@@ -400,25 +402,10 @@ QueryService.newQuery = function newQuery() {
|
|||||||
name: "New Query",
|
name: "New Query",
|
||||||
schedule: null,
|
schedule: null,
|
||||||
user: currentUser,
|
user: currentUser,
|
||||||
options: {},
|
options: { apply_auto_limit: localOptions.get("applyAutoLimit", true) },
|
||||||
tags: [],
|
tags: [],
|
||||||
can_edit: true,
|
can_edit: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
QueryService.format = function formatQuery(syntax, query) {
|
|
||||||
if (syntax === "json") {
|
|
||||||
try {
|
|
||||||
const formatted = JSON.stringify(JSON.parse(query), " ", 4);
|
|
||||||
return Promise.resolve(formatted);
|
|
||||||
} catch (err) {
|
|
||||||
return Promise.reject(String(err));
|
|
||||||
}
|
|
||||||
} else if (syntax === "sql") {
|
|
||||||
return axios.post("api/queries/format", { query }).then(data => data.query);
|
|
||||||
} else {
|
|
||||||
return Promise.reject("Query formatting is not supported for your data source syntax.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
extend(Query, QueryService);
|
extend(Query, QueryService);
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ describe("Embedded Queries", () => {
|
|||||||
|
|
||||||
// check the feature is disabled
|
// check the feature is disabled
|
||||||
cy.visit(`/queries/${query.id}/source`);
|
cy.visit(`/queries/${query.id}/source`);
|
||||||
cy.getByTestId("ExecuteButton").click();
|
|
||||||
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
|
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
|
||||||
cy.getByTestId("QueryPageHeaderMoreButton").click();
|
cy.getByTestId("QueryPageHeaderMoreButton").click();
|
||||||
cy.get(".ant-dropdown-menu-item")
|
cy.get(".ant-dropdown-menu-item")
|
||||||
|
|||||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -5070,6 +5070,12 @@
|
|||||||
"integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==",
|
"integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@types/sql-formatter": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/sql-formatter/-/sql-formatter-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-Xh9kEOaKWhm3vYD5lUjYFFiSfpN4y3/iQCJUAVwFaQ1rVvHs4WXTa5C8E7gyF3kxwsMS8KgttW7WBAPtFlsvAg==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@typescript-eslint/eslint-plugin": {
|
"@typescript-eslint/eslint-plugin": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.10.0.tgz",
|
||||||
@@ -22960,6 +22966,13 @@
|
|||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
|
||||||
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
|
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
|
||||||
},
|
},
|
||||||
|
"sql-formatter": {
|
||||||
|
"version": "git+https://github.com/getredash/sql-formatter.git#b61a6dce3b451e38e87090b0675a43be1638e5b6",
|
||||||
|
"from": "git+https://github.com/getredash/sql-formatter.git",
|
||||||
|
"requires": {
|
||||||
|
"lodash": "^4.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sshpk": {
|
"sshpk": {
|
||||||
"version": "1.16.1",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"react-grid-layout": "^0.18.2",
|
"react-grid-layout": "^0.18.2",
|
||||||
"react-resizable": "^1.10.1",
|
"react-resizable": "^1.10.1",
|
||||||
"react-virtualized": "^9.21.2",
|
"react-virtualized": "^9.21.2",
|
||||||
|
"sql-formatter": "git+https://github.com/getredash/sql-formatter.git",
|
||||||
"universal-router": "^8.3.0",
|
"universal-router": "^8.3.0",
|
||||||
"use-debounce": "^3.1.0",
|
"use-debounce": "^3.1.0",
|
||||||
"use-media": "^1.4.0"
|
"use-media": "^1.4.0"
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
"@types/prop-types": "^15.7.3",
|
"@types/prop-types": "^15.7.3",
|
||||||
"@types/react": "^16.9.41",
|
"@types/react": "^16.9.41",
|
||||||
"@types/react-dom": "^16.9.8",
|
"@types/react-dom": "^16.9.8",
|
||||||
|
"@types/sql-formatter": "^2.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^2.10.0",
|
"@typescript-eslint/eslint-plugin": "^2.10.0",
|
||||||
"@typescript-eslint/parser": "^2.10.0",
|
"@typescript-eslint/parser": "^2.10.0",
|
||||||
"atob": "^2.1.2",
|
"atob": "^2.1.2",
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ export default function initChart(container, options, data, additionalOptions, o
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
options.onHover && container.on("plotly_hover", options.onHover);
|
||||||
|
options.onUnHover && container.on("plotly_unhover", options.onUnHover);
|
||||||
|
|
||||||
unwatchResize = resizeObserver(
|
unwatchResize = resizeObserver(
|
||||||
container,
|
container,
|
||||||
|
|||||||
Reference in New Issue
Block a user