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:
Gabriel Dutra
2020-11-10 09:59:15 -03:00
committed by GitHub
parent 8f484706b1
commit fa7ecca485
58 changed files with 746 additions and 424 deletions

View File

@@ -6,9 +6,12 @@ ARG skip_frontend_build
ENV CYPRESS_INSTALL_BINARY=0 ENV CYPRESS_INSTALL_BINARY=0
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
RUN useradd -m -d /frontend redash
USER redash
WORKDIR /frontend WORKDIR /frontend
COPY package.json package-lock.json /frontend/ COPY --chown=redash package.json package-lock.json /frontend/
COPY viz-lib /frontend/viz-lib COPY --chown=redash viz-lib /frontend/viz-lib
# Controls whether to instrument code for coverage information # Controls whether to instrument code for coverage information
ARG code_coverage ARG code_coverage
@@ -16,8 +19,8 @@ ENV BABEL_ENV=${code_coverage:+test}
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi
COPY client /frontend/client COPY --chown=redash client /frontend/client
COPY webpack.config.js /frontend/ COPY --chown=redash webpack.config.js /frontend/
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
FROM python:3.7-slim FROM python:3.7-slim

View File

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

View File

@@ -141,6 +141,7 @@ a.label-tag {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
position: relative;
} }
.query-fullscreen { .query-fullscreen {

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ function Link(props) {
Link.Component = DefaultLinkComponent; Link.Component = DefaultLinkComponent;
function DefaultButtonLinkComponent(props) { function DefaultButtonLinkComponent(props) {
return <Button {...props} />; return <Button role="button" {...props} />;
} }
function ButtonLink(props) { function ButtonLink(props) {

View File

@@ -4,13 +4,13 @@ import PropTypes from "prop-types";
import Tag from "antd/lib/tag"; import Tag from "antd/lib/tag";
import Link from "@/components/Link"; import Link from "@/components/Link";
export default function UserGroups({ groups, ...props }) { import "./UserGroups.less";
export default function UserGroups({ groups, linkGroups, ...props }) {
return ( return (
<div {...props}> <div className="user-groups" {...props}>
{map(groups, group => ( {map(groups, group => (
<Tag className="m-b-5 m-r-5" key={group.id}> <Tag key={group.id}>{linkGroups ? <Link href={`groups/${group.id}`}>{group.name}</Link> : group.name}</Tag>
<Link href={`groups/${group.id}`}>{group.name}</Link>
</Tag>
))} ))}
</div> </div>
); );
@@ -19,12 +19,14 @@ export default function UserGroups({ groups, ...props }) {
UserGroups.propTypes = { UserGroups.propTypes = {
groups: PropTypes.arrayOf( groups: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
id: PropTypes.number.isRequired, id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
name: PropTypes.string, name: PropTypes.string,
}) })
), ),
linkGroups: PropTypes.bool,
}; };
UserGroups.defaultProps = { UserGroups.defaultProps = {
groups: [], groups: [],
linkGroups: true,
}; };

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,8 @@
opacity: 0; opacity: 0;
} }
.ant-picker-separator { .ant-picker-separator,
.ant-picker-range-separator {
display: none; display: none;
} }

View File

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

View File

@@ -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&apos;s get started</h4> <div className="empty-state__steps">
<ol>{stepsItems.map(item => item.node)}</ol> <h4>Let&apos;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,

View File

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

View File

@@ -67,10 +67,10 @@ export const Columns = {
overrides overrides
); );
}, },
timeAgo(overrides) { timeAgo(overrides, timeAgoCustomProps = undefined) {
return extend( return extend(
{ {
render: value => <TimeAgo date={value} />, render: value => <TimeAgo date={value} {...timeAgoCustomProps} />,
}, },
overrides overrides
); );
@@ -110,6 +110,8 @@ export default class ItemsTable extends React.Component {
orderByField: PropTypes.string, orderByField: PropTypes.string,
orderByReverse: PropTypes.bool, orderByReverse: PropTypes.bool,
toggleSorting: PropTypes.func, toggleSorting: PropTypes.func,
"data-test": PropTypes.string,
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
}; };
static defaultProps = { static defaultProps = {
@@ -151,6 +153,17 @@ export default class ItemsTable extends React.Component {
); );
} }
getRowKey = record => {
const { rowKey } = this.props;
if (rowKey) {
if (isFunction(rowKey)) {
return rowKey(record.item);
}
return record.item[rowKey];
}
return record.key;
};
render() { render() {
const tableDataProps = { const tableDataProps = {
columns: this.prepareColumns(), columns: this.prepareColumns(),
@@ -184,9 +197,10 @@ export default class ItemsTable extends React.Component {
<Table <Table
className={classNames("table-data", { "ant-table-headerless": !showHeader })} className={classNames("table-data", { "ant-table-headerless": !showHeader })}
showHeader={showHeader} showHeader={showHeader}
rowKey={row => row.key} rowKey={this.getRowKey}
pagination={false} pagination={false}
onRow={onTableRow} onRow={onTableRow}
data-test={this.props["data-test"]}
{...tableDataProps} {...tableDataProps}
/> />
); );

View File

@@ -11,6 +11,7 @@ import { clientConfig } from "@/services/auth";
import notification from "@/services/notification"; import notification from "@/services/notification";
import "./index.less"; import "./index.less";
import { policy } from "@/services/policy";
function ApiKeyDialog({ dialog, ...props }) { function ApiKeyDialog({ dialog, ...props }) {
const [query, setQuery] = useState(props.query); const [query, setQuery] = useState(props.query);
@@ -45,7 +46,7 @@ function ApiKeyDialog({ dialog, ...props }) {
<div className="m-b-20"> <div className="m-b-20">
<Input.Group compact> <Input.Group compact>
<Input readOnly value={query.api_key} /> <Input readOnly value={query.api_key} />
{query.can_edit && ( {policy.canEdit(query) && (
<Button disabled={updatingApiKey} loading={updatingApiKey} onClick={regenerateQueryApiKey}> <Button disabled={updatingApiKey} loading={updatingApiKey} onClick={regenerateQueryApiKey}>
Regenerate Regenerate
</Button> </Button>

View File

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

View File

@@ -67,10 +67,6 @@ export default function DatabricksSchemaBrowser({
setExpandedFlags({}); setExpandedFlags({});
}, [currentDatabaseName]); }, [currentDatabaseName]);
if (schema.length === 0 && databases.length === 0 && !(loadingDatabases || loadingSchema)) {
return null;
}
function toggleTable(tableName) { function toggleTable(tableName) {
const table = find(schema, { name: tableName }); const table = find(schema, { name: tableName });
if (!expandedFlags[tableName] && get(table, "loading", false)) { if (!expandedFlags[tableName] && get(table, "loading", false)) {

View File

@@ -1,4 +1,4 @@
import { has, get, map, first, isFunction, isEmpty } from "lodash"; import { includes, has, get, map, first, isFunction, isEmpty, startsWith } from "lodash";
import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import notification from "@/services/notification"; import notification from "@/services/notification";
import DatabricksDataSource from "@/services/databricks-data-source"; import DatabricksDataSource from "@/services/databricks-data-source";
@@ -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(() => {

View File

@@ -0,0 +1,56 @@
import { Query } from "@/services/query";
import * as queryFormat from "./queryFormat";
describe("QueryFormat.formatQuery", () => {
test("returns same query text when syntax is not supported", () => {
const unsupportedSyntax = "unsupported-syntax";
const queryText = "select * from example";
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(unsupportedSyntax);
const formattedQuery = queryFormat.formatQuery(queryText, unsupportedSyntax);
expect(isFormatQueryAvailable).toBeFalsy();
expect(formattedQuery).toBe(queryText);
});
describe("sql", () => {
const syntax = "sql";
test("returns the formatted query text", () => {
const queryText = "select column1, column2 from example where column1 = 2";
const expectedFormattedQueryText = [
"select",
" column1,",
" column2",
"from",
" example",
"where",
" column1 = 2",
].join("\n");
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(syntax);
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
expect(isFormatQueryAvailable).toBeTruthy();
expect(formattedQueryText).toBe(expectedFormattedQueryText);
});
test("still recognizes parameters after formatting", () => {
const queryText = "select {{param1}}, {{ param2 }}, {{ date-range.start }} from example";
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
const queryParameters = new Query({ query: queryText }).getParameters().parseQuery();
const formattedQueryParameters = new Query({ query: formattedQueryText }).getParameters().parseQuery();
expect(formattedQueryParameters.sort()).toEqual(queryParameters.sort());
});
});
describe("json", () => {
const syntax = "json";
test("returns the formatted query text", () => {
const queryText = '{"collection": "example","limit": 10}';
const expectedFormattedQueryText = '{\n "collection": "example",\n "limit": 10\n}';
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(syntax);
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
expect(isFormatQueryAvailable).toBeTruthy();
expect(formattedQueryText).toBe(expectedFormattedQueryText);
});
});
});

View File

@@ -0,0 +1,23 @@
import { trim } from "lodash";
import sqlFormatter from "sql-formatter";
interface QueryFormatterMap {
[syntax: string]: (queryText: string) => string;
}
const QueryFormatters: QueryFormatterMap = {
sql: queryText => sqlFormatter.format(trim(queryText)),
json: queryText => JSON.stringify(JSON.parse(queryText), null, 4),
};
export function isFormatQueryAvailable(syntax: string) {
return syntax in QueryFormatters;
}
export function formatQuery(queryText: string, syntax: string) {
if (!isFormatQueryAvailable(syntax)) {
return queryText;
}
const formatter = QueryFormatters[syntax];
return formatter(queryText);
}

View File

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

View File

@@ -2,6 +2,7 @@ import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import HelpTrigger from "@/components/HelpTrigger"; import HelpTrigger from "@/components/HelpTrigger";
import DynamicComponent from "@/components/DynamicComponent";
import { Alert as AlertType } from "@/components/proptypes"; import { Alert as AlertType } from "@/components/proptypes";
import Form from "antd/lib/form"; import Form from "antd/lib/form";
@@ -52,6 +53,7 @@ export default class AlertEdit extends React.Component {
return ( return (
<> <>
<Title name={name} alert={alert} onChange={onNameChange} editMode> <Title name={name} alert={alert} onChange={onNameChange} editMode>
<DynamicComponent name="AlertEdit.HeaderExtra" alert={alert} />
<Button className="m-r-5" onClick={() => this.cancel()}> <Button className="m-r-5" onClick={() => this.cancel()}>
<i className="fa fa-times m-r-5" /> <i className="fa fa-times m-r-5" />
Cancel Cancel

View File

@@ -19,6 +19,7 @@ import Query from "./components/Query";
import AlertDestinations from "./components/AlertDestinations"; import AlertDestinations from "./components/AlertDestinations";
import HorizontalFormItem from "./components/HorizontalFormItem"; import HorizontalFormItem from "./components/HorizontalFormItem";
import { STATE_CLASS } from "../alerts/AlertsList"; import { STATE_CLASS } from "../alerts/AlertsList";
import DynamicComponent from "@/components/DynamicComponent";
function AlertState({ state, lastTriggered }) { function AlertState({ state, lastTriggered }) {
return ( return (
@@ -66,6 +67,7 @@ export default class AlertView extends React.Component {
return ( return (
<> <>
<Title name={name} alert={alert}> <Title name={name} alert={alert}>
<DynamicComponent name="AlertView.HeaderExtra" alert={alert} />
<Tooltip title={canEdit ? "" : "You do not have sufficient permissions to edit this alert"}> <Tooltip title={canEdit ? "" : "You do not have sufficient permissions to edit this alert"}>
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}> <Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
<i className="fa fa-edit m-r-5" /> <i className="fa fa-edit m-r-5" />

View File

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

View File

@@ -15,6 +15,7 @@ import ShareDashboardDialog from "../components/ShareDashboardDialog";
import useFullscreenHandler from "../../../lib/hooks/useFullscreenHandler"; import useFullscreenHandler from "../../../lib/hooks/useFullscreenHandler";
import useRefreshRateHandler from "./useRefreshRateHandler"; import useRefreshRateHandler from "./useRefreshRateHandler";
import useEditModeHandler from "./useEditModeHandler"; import useEditModeHandler from "./useEditModeHandler";
import { policy } from "@/services/policy";
export { DashboardStatusEnum } from "./useEditModeHandler"; export { DashboardStatusEnum } from "./useEditModeHandler";
@@ -39,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 &&

View File

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

View File

@@ -0,0 +1,94 @@
import { isEmpty } from "lodash";
import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import Link from "@/components/Link";
import LoadingOutlinedIcon from "@ant-design/icons/LoadingOutlined";
import { Dashboard } from "@/services/dashboard";
import { Query } from "@/services/query";
export function FavoriteList({ title, resource, itemUrl, emptyState }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
resource
.favorites()
.then(({ results }) => setItems(results))
.finally(() => setLoading(false));
}, [resource]);
return (
<>
<div className="d-flex align-items-center m-b-20">
<p className="flex-fill f-500 c-black m-0">{title}</p>
{loading && <LoadingOutlinedIcon />}
</div>
{!isEmpty(items) && (
<div className="list-group">
{items.map(item => (
<Link key={itemUrl(item)} className="list-group-item" href={itemUrl(item)}>
<span className="btn-favourite m-r-5">
<i className="fa fa-star" aria-hidden="true" />
</span>
{item.name}
{item.is_draft && <span className="label label-default m-l-5">Unpublished</span>}
</Link>
))}
</div>
)}
{isEmpty(items) && !loading && emptyState}
</>
);
}
FavoriteList.propTypes = {
title: PropTypes.string.isRequired,
resource: PropTypes.func.isRequired, // eslint-disable-line react/forbid-prop-types
itemUrl: PropTypes.func.isRequired,
emptyState: PropTypes.node,
};
FavoriteList.defaultProps = { emptyState: null };
export function DashboardAndQueryFavoritesList() {
return (
<div className="tile">
<div className="t-body tb-padding">
<div className="row home-favorites-list">
<div className="col-sm-6 m-t-20">
<FavoriteList
title="Favorite Dashboards"
resource={Dashboard}
itemUrl={dashboard => dashboard.url}
emptyState={
<p>
<span className="btn-favourite m-r-5">
<i className="fa fa-star" aria-hidden="true" />
</span>
Favorite <Link href="dashboards">Dashboards</Link> will appear here
</p>
}
/>
</div>
<div className="col-sm-6 m-t-20">
<FavoriteList
title="Favorite Queries"
resource={Query}
itemUrl={query => `queries/${query.id}`}
emptyState={
<p>
<span className="btn-favourite m-r-5">
<i className="fa fa-star" aria-hidden="true" />
</span>
Favorite <Link href="queries">Queries</Link> will appear here
</p>
}
/>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -116,7 +116,7 @@ export default function QueryPageHeader({
onClick: publishQuery, onClick: publishQuery,
}, },
unpublish: { unpublish: {
isAvailable: !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isDraft, isAvailable: !clientConfig.disablePublish && !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isDraft,
title: "Unpublish", title: "Unpublish",
onClick: unpublishQuery, onClick: unpublishQuery,
}, },
@@ -179,7 +179,7 @@ export default function QueryPageHeader({
{!queryFlags.isNew && queryFlags.canViewSource && ( {!queryFlags.isNew && queryFlags.canViewSource && (
<span> <span>
{!sourceMode && ( {!sourceMode && queryFlags.canEdit && (
<Link.Button className="m-r-5" href={query.getUrl(true, selectedVisualization)}> <Link.Button className="m-r-5" href={query.getUrl(true, selectedVisualization)}>
<i className="fa fa-pencil-square-o" aria-hidden="true" /> <i className="fa fa-pencil-square-o" aria-hidden="true" />
<span className="m-l-5">Edit Source</span> <span className="m-l-5">Edit Source</span>
@@ -211,7 +211,7 @@ export default function QueryPageHeader({
QueryPageHeader.propTypes = { QueryPageHeader.propTypes = {
query: PropTypes.shape({ query: PropTypes.shape({
id: PropTypes.number, id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
name: PropTypes.string, name: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string), tags: PropTypes.arrayOf(PropTypes.string),
}).isRequired, }).isRequired,

View File

@@ -4,6 +4,7 @@ import Card from "antd/lib/card";
import WarningFilledIcon from "@ant-design/icons/WarningFilled"; import WarningFilledIcon from "@ant-design/icons/WarningFilled";
import Typography from "antd/lib/typography"; import Typography from "antd/lib/typography";
import Link from "@/components/Link"; import Link from "@/components/Link";
import DynamicComponent from "@/components/DynamicComponent";
import { currentUser } from "@/services/auth"; import { currentUser } from "@/services/auth";
import useQueryFlags from "../hooks/useQueryFlags"; import useQueryFlags from "../hooks/useQueryFlags";
@@ -69,10 +70,12 @@ export default function QuerySourceAlerts({ query, dataSourcesAvailable }) {
return ( return (
<div className="query-source-alerts"> <div className="query-source-alerts">
<Card> <Card>
<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>
); );

View File

@@ -91,6 +91,7 @@ export default function QueryVisualizationTabs({
onAddVisualization, onAddVisualization,
onDeleteVisualization, onDeleteVisualization,
refreshButton, refreshButton,
canRefresh,
...props ...props
}) { }) {
const visualizations = useMemo( const visualizations = useMemo(
@@ -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,
}; };

View File

@@ -8,16 +8,16 @@ function isAutoLimitAvailable(dataSource) {
export default function useAutoLimitFlags(dataSource, query, setQuery) { export default function useAutoLimitFlags(dataSource, query, setQuery) {
const isAvailable = isAutoLimitAvailable(dataSource); const isAvailable = isAutoLimitAvailable(dataSource);
const [isChecked, setIsChecked] = useState(localOptions.get("applyAutoLimit", true)); const [isChecked, setIsChecked] = useState(query.options.apply_auto_limit);
query.options.apply_auto_limit = isAvailable && isChecked; query.options.apply_auto_limit = isChecked;
const setAutoLimit = useCallback( const setAutoLimit = useCallback(
state => { state => {
setIsChecked(state); setIsChecked(state);
localOptions.set("applyAutoLimit", state); localOptions.set("applyAutoLimit", state);
setQuery(extend(query.clone(), { options: { ...query.options, apply_auto_limit: isAvailable && state } })); setQuery(extend(query.clone(), { options: { ...query.options, apply_auto_limit: state } }));
}, },
[query, setQuery, isAvailable] [query, setQuery]
); );
return [isAvailable, isChecked, setAutoLimit]; return [isAvailable, isChecked, setAutoLimit];

View File

@@ -1,19 +0,0 @@
import { extend, get } from "lodash";
import { useCallback } from "react";
import { Query } from "@/services/query";
import notification from "@/services/notification";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
export default function useFormatQuery(query, syntax, onChange) {
const handleChange = useImmutableCallback(onChange);
return useCallback(() => {
Query.format(syntax || "sql", query.query)
.then(queryText => {
handleChange(extend(query.clone(), { query: queryText }));
})
.catch(error =>
notification.error(get(error, "response.data.message", "Failed to format query: unknown error."))
);
}, [query, syntax, handleChange]);
}

View File

@@ -1,3 +1,4 @@
import { isEmpty } from "lodash";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import useUpdateQuery from "./useUpdateQuery"; import useUpdateQuery from "./useUpdateQuery";
import navigateTo from "@/components/ApplicationArea/navigateTo"; import navigateTo from "@/components/ApplicationArea/navigateTo";
@@ -5,6 +6,7 @@ import navigateTo from "@/components/ApplicationArea/navigateTo";
export default function useQuery(originalQuery) { export default function useQuery(originalQuery) {
const [query, setQuery] = useState(originalQuery); const [query, setQuery] = useState(originalQuery);
const [originalQuerySource, setOriginalQuerySource] = useState(originalQuery.query); const [originalQuerySource, setOriginalQuerySource] = useState(originalQuery.query);
const [originalAutoLimit, setOriginalAutoLimit] = useState(query.options.apply_auto_limit);
const updateQuery = useUpdateQuery(query, updatedQuery => { const updateQuery = useUpdateQuery(query, updatedQuery => {
// It's important to update URL first, and only then update state // It's important to update URL first, and only then update state
@@ -14,15 +16,18 @@ export default function useQuery(originalQuery) {
} }
setQuery(updatedQuery); setQuery(updatedQuery);
setOriginalQuerySource(updatedQuery.query); setOriginalQuerySource(updatedQuery.query);
setOriginalAutoLimit(updatedQuery.options.apply_auto_limit);
}); });
return useMemo( return useMemo(
() => ({ () => ({
query, query,
setQuery, setQuery,
isDirty: query.query !== originalQuerySource, isDirty:
query.query !== originalQuerySource ||
(!isEmpty(query.query) && query.options.apply_auto_limit !== originalAutoLimit),
saveQuery: () => updateQuery(), saveQuery: () => updateQuery(),
}), }),
[query, originalQuerySource, updateQuery] [query, originalQuerySource, updateQuery, originalAutoLimit]
); );
} }

View File

@@ -1,6 +1,7 @@
import { isNil, isEmpty } from "lodash"; import { isNil, isEmpty } from "lodash";
import { useMemo } from "react"; import { useMemo } from "react";
import { currentUser } from "@/services/auth"; import { currentUser } from "@/services/auth";
import { policy } from "@/services/policy";
export default function useQueryFlags(query, dataSource = null) { export default function useQueryFlags(query, dataSource = null) {
dataSource = dataSource || { view_only: true }; dataSource = dataSource || { view_only: true };
@@ -15,10 +16,11 @@ export default function useQueryFlags(query, dataSource = null) {
// permissions flags // permissions flags
canCreate: currentUser.hasPermission("create_query"), canCreate: currentUser.hasPermission("create_query"),
canView: currentUser.hasPermission("view_query"), canView: currentUser.hasPermission("view_query"),
canEdit: currentUser.hasPermission("edit_query") && query.can_edit, canEdit: currentUser.hasPermission("edit_query") && policy.canEdit(query),
canViewSource: currentUser.hasPermission("view_source"), canViewSource: currentUser.hasPermission("view_source"),
canExecute: canExecute:
!isEmpty(query.query) && !isEmpty(query.query) &&
policy.canRun(query) &&
(query.is_safe || (currentUser.hasPermission("execute_query") && !dataSource.view_only)), (query.is_safe || (currentUser.hasPermission("execute_query") && !dataSource.view_only)),
canFork: currentUser.hasPermission("edit_query") && !dataSource.view_only, canFork: currentUser.hasPermission("edit_query") && !dataSource.view_only,
canSchedule: currentUser.hasPermission("schedule_query"), canSchedule: currentUser.hasPermission("schedule_query"),

View File

@@ -4,6 +4,7 @@ import Modal from "antd/lib/modal";
import { Query } from "@/services/query"; import { Query } from "@/services/query";
import notification from "@/services/notification"; import notification from "@/services/notification";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback"; import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import { policy } from "@/services/policy";
class SaveQueryError extends Error { class SaveQueryError extends Error {
constructor(message, detailedMessage = null) { constructor(message, detailedMessage = null) {
@@ -94,10 +95,11 @@ export default function useUpdateQuery(query, onChange) {
"options", "options",
"latest_query_data_id", "latest_query_data_id",
"is_draft", "is_draft",
"tags",
]); ]);
} }
return doSaveQuery(data, { canOverwrite: query.can_edit }) return doSaveQuery(data, { canOverwrite: policy.canEdit(query) })
.then(updatedQuery => { .then(updatedQuery => {
if (!isNil(successMessage)) { if (!isNil(successMessage)) {
notification.success(successMessage); notification.success(successMessage);

View File

@@ -1,89 +1,37 @@
import { get } from "lodash"; import React from "react";
import React, { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Form from "antd/lib/form"; import Form from "antd/lib/form";
import Skeleton from "antd/lib/skeleton";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession"; import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import LoadingState from "@/components/items-list/components/LoadingState";
import wrapSettingsTab from "@/components/SettingsWrapper"; import wrapSettingsTab from "@/components/SettingsWrapper";
import recordEvent from "@/services/recordEvent";
import OrgSettings from "@/services/organizationSettings";
import routes from "@/services/routes"; import routes from "@/services/routes";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import { getHorizontalFormProps, getHorizontalFormItemWithoutLabelProps } from "@/styles/formStyle"; import { getHorizontalFormProps, getHorizontalFormItemWithoutLabelProps } from "@/styles/formStyle";
import useOrganizationSettings from "./hooks/useOrganizationSettings";
import GeneralSettings from "./components/GeneralSettings"; import GeneralSettings from "./components/GeneralSettings";
import AuthSettings from "./components/AuthSettings"; import AuthSettings from "./components/AuthSettings";
function OrganizationSettings({ onError }) { function OrganizationSettings({ onError }) {
const [settings, setSettings] = useState({}); const { settings, currentValues, isLoading, isSaving, handleSubmit, handleChange } = useOrganizationSettings(onError);
const [currentValues, setCurrentValues] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const handleError = useImmutableCallback(onError);
useEffect(() => {
recordEvent("view", "page", "org_settings");
let isCancelled = false;
OrgSettings.get()
.then(response => {
if (!isCancelled) {
const settings = get(response, "settings");
setSettings(settings);
setCurrentValues({ ...settings });
setIsLoading(false);
}
})
.catch(error => {
if (!isCancelled) {
handleError(error);
}
});
return () => {
isCancelled = true;
};
}, [handleError]);
const handleChange = useCallback(changes => {
setCurrentValues(currentValues => ({ ...currentValues, ...changes }));
}, []);
const handleSubmit = useCallback(() => {
if (!isSaving) {
setIsSaving(true);
OrgSettings.save(currentValues)
.then(response => {
const settings = get(response, "settings");
setSettings(settings);
setCurrentValues({ ...settings });
})
.catch(handleError)
.finally(() => setIsSaving(false));
}
}, [isSaving, currentValues, handleError]);
return ( return (
<div className="row" data-test="OrganizationSettings"> <div className="row" data-test="OrganizationSettings">
<div className="m-r-20 m-l-20"> <div className="m-r-20 m-l-20">
{isLoading ? ( <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>
); );

View File

@@ -3,19 +3,20 @@ import Alert from "antd/lib/alert";
import Form from "antd/lib/form"; import Form from "antd/lib/form";
import Checkbox from "antd/lib/checkbox"; import Checkbox from "antd/lib/checkbox";
import Tooltip from "antd/lib/tooltip"; import Tooltip from "antd/lib/tooltip";
import Skeleton from "antd/lib/skeleton";
import DynamicComponent from "@/components/DynamicComponent"; import DynamicComponent from "@/components/DynamicComponent";
import { clientConfig } from "@/services/auth"; import { clientConfig } from "@/services/auth";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types"; import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
export default function PasswordLoginSettings(props) { export default function PasswordLoginSettings(props) {
const { settings, values, onChange } = props; const { settings, values, onChange, loading } = props;
const isTheOnlyAuthMethod = const isTheOnlyAuthMethod =
!clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !values.auth_saml_enabled; !clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !values.auth_saml_enabled;
return ( return (
<DynamicComponent name="OrganizationSettings.PasswordLoginSettings" {...props}> <DynamicComponent name="OrganizationSettings.PasswordLoginSettings" {...props}>
{!settings.auth_password_login_enabled && ( {!loading && !settings.auth_password_login_enabled && (
<Alert <Alert
message="Password based login is currently disabled and users will message="Password based login is currently disabled and users will
be able to login only with the enabled SSO options." be able to login only with the enabled SSO options."
@@ -24,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>
); );

View File

@@ -1,12 +1,13 @@
import React from "react"; import React from "react";
import Form from "antd/lib/form"; import Form from "antd/lib/form";
import 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 && (
<> <>

View File

@@ -1,12 +1,13 @@
import React from "react"; import React from "react";
import Form from "antd/lib/form"; import Form from "antd/lib/form";
import HelpTrigger from "@/components/HelpTrigger";
import Checkbox from "antd/lib/checkbox"; import Checkbox from "antd/lib/checkbox";
import Skeleton from "antd/lib/skeleton";
import HelpTrigger from "@/components/HelpTrigger";
import DynamicComponent from "@/components/DynamicComponent"; import DynamicComponent from "@/components/DynamicComponent";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types"; import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
export default function BeaconConsentSettings(props) { export default function BeaconConsentSettings(props) {
const { values, onChange } = props; const { values, onChange, loading } = props;
return ( return (
<DynamicComponent name="OrganizationSettings.BeaconConsentSettings" {...props}> <DynamicComponent name="OrganizationSettings.BeaconConsentSettings" {...props}>
@@ -17,12 +18,16 @@ export default function BeaconConsentSettings(props) {
<HelpTrigger className="m-l-5 m-r-5" type="USAGE_DATA_SHARING" /> <HelpTrigger className="m-l-5 m-r-5" type="USAGE_DATA_SHARING" />
</span> </span>
}> }>
<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>
); );

View File

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

View File

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

View File

@@ -1,21 +1,26 @@
import React from "react"; import React from "react";
import Checkbox from "antd/lib/checkbox"; import Checkbox from "antd/lib/checkbox";
import Form from "antd/lib/form"; import Form from "antd/lib/form";
import Skeleton from "antd/lib/skeleton";
import DynamicComponent from "@/components/DynamicComponent"; import DynamicComponent from "@/components/DynamicComponent";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types"; import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
export default function PlotlySettings(props) { export default function PlotlySettings(props) {
const { values, onChange } = props; const { values, onChange, loading } = props;
return ( return (
<DynamicComponent name="OrganizationSettings.PlotlySettings" {...props}> <DynamicComponent name="OrganizationSettings.PlotlySettings" {...props}>
<Form.Item label="Chart Visualization"> <Form.Item label="Chart Visualization">
<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>
); );

View File

@@ -4,10 +4,12 @@ export const SettingsEditorPropTypes = {
settings: PropTypes.object, settings: PropTypes.object,
values: PropTypes.object, values: PropTypes.object,
onChange: PropTypes.func, // (key, value) => void onChange: PropTypes.func, // (key, value) => void
loading: PropTypes.bool,
}; };
export const SettingsEditorDefaultProps = { export const SettingsEditorDefaultProps = {
settings: {}, settings: {},
values: {}, values: {},
onChange: () => {}, onChange: () => {},
loading: false,
}; };

View File

@@ -0,0 +1,59 @@
import { get } from "lodash";
import { useState, useEffect, useCallback } from "react";
import recordEvent from "@/services/recordEvent";
import OrgSettings from "@/services/organizationSettings";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
export default function useOrganizationSettings({ onError }) {
const [settings, setSettings] = useState({});
const [currentValues, setCurrentValues] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const handleError = useImmutableCallback(onError);
useEffect(() => {
recordEvent("view", "page", "org_settings");
let isCancelled = false;
OrgSettings.get()
.then(response => {
if (!isCancelled) {
const settings = get(response, "settings");
setSettings(settings);
setCurrentValues({ ...settings });
setIsLoading(false);
}
})
.catch(error => {
if (!isCancelled) {
handleError(error);
}
});
return () => {
isCancelled = true;
};
}, [handleError]);
const handleChange = useCallback(changes => {
setCurrentValues(currentValues => ({ ...currentValues, ...changes }));
}, []);
const handleSubmit = useCallback(() => {
if (!isSaving) {
setIsSaving(true);
OrgSettings.save(currentValues)
.then(response => {
const settings = get(response, "settings");
setSettings(settings);
setCurrentValues({ ...settings });
})
.catch(handleError)
.finally(() => setIsSaving(false));
}
}, [isSaving, currentValues, handleError]);
return { settings, currentValues, isLoading, isSaving, handleSubmit, handleChange };
}

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { UserProfile } from "@/components/proptypes"; import { UserProfile } from "@/components/proptypes";
import UserGroups from "@/components/UserGroups";
import UserGroups from "./UserGroups";
import useUserGroups from "../hooks/useUserGroups"; import useUserGroups from "../hooks/useUserGroups";
export default function ReadOnlyUserProfile({ user }) { export default function ReadOnlyUserProfile({ user }) {

View File

@@ -4,12 +4,12 @@ import PropTypes from "prop-types";
import { UserProfile } from "@/components/proptypes"; import { UserProfile } from "@/components/proptypes";
import DynamicComponent from "@/components/DynamicComponent"; import DynamicComponent from "@/components/DynamicComponent";
import DynamicForm from "@/components/dynamic-form/DynamicForm"; import DynamicForm from "@/components/dynamic-form/DynamicForm";
import UserGroups from "@/components/UserGroups";
import User from "@/services/user"; import User from "@/services/user";
import { currentUser } from "@/services/auth"; import { currentUser } from "@/services/auth";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback"; import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import UserGroups from "./UserGroups";
import useUserGroups from "../hooks/useUserGroups"; import useUserGroups from "../hooks/useUserGroups";
export default function UserInfoForm(props) { export default function UserInfoForm(props) {

View File

@@ -2,9 +2,9 @@ import _ from "lodash";
import { axios } from "@/services/axios"; import { axios } from "@/services/axios";
import dashboardGridOptions from "@/config/dashboard-grid-options"; import dashboardGridOptions from "@/config/dashboard-grid-options";
import Widget from "./widget"; import Widget from "./widget";
import { currentUser } from "@/services/auth";
import location from "@/services/location"; import location from "@/services/location";
import { cloneParameter } from "@/services/parameters"; import { cloneParameter } from "@/services/parameters";
import { policy } from "@/services/policy";
export const urlForDashboard = ({ id, slug }) => `dashboards/${id}-${slug}`; export const urlForDashboard = ({ id, slug }) => `dashboards/${id}-${slug}`;
@@ -179,7 +179,7 @@ Dashboard.prepareDashboardWidgets = prepareDashboardWidgets;
Dashboard.prepareWidgetsForDashboard = prepareWidgetsForDashboard; Dashboard.prepareWidgetsForDashboard = prepareWidgetsForDashboard;
Dashboard.prototype.canEdit = function canEdit() { Dashboard.prototype.canEdit = function canEdit() {
return currentUser.canEdit(this) || this.can_edit; return policy.canEdit(this);
}; };
Dashboard.prototype.getParametersDefs = function getParametersDefs() { Dashboard.prototype.getParametersDefs = function getParametersDefs() {

View File

@@ -1,4 +1,4 @@
import { isArray } from "lodash"; import { get, isArray } from "lodash";
import { currentUser, clientConfig } from "@/services/auth"; import { currentUser, clientConfig } from "@/services/auth";
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
@@ -57,4 +57,12 @@ export default class DefaultPolicy {
const result = clientConfig.queryRefreshIntervals; const result = clientConfig.queryRefreshIntervals;
return isArray(result) ? result : null; return isArray(result) ? result : null;
} }
canEdit(object) {
return get(object, "can_edit", false);
}
canRun() {
return true;
}
} }

View File

@@ -24,6 +24,7 @@ import location from "@/services/location";
import { Parameter, createParameter } from "./parameters"; import { Parameter, createParameter } from "./parameters";
import { currentUser } from "./auth"; import { currentUser } from "./auth";
import QueryResult from "./query-result"; import QueryResult from "./query-result";
import localOptions from "@/lib/localOptions";
Mustache.escape = identity; // do not html-escape values Mustache.escape = identity; // do not html-escape values
@@ -50,6 +51,7 @@ export class Query {
if (!has(this, "options")) { if (!has(this, "options")) {
this.options = {}; this.options = {};
} }
this.options.apply_auto_limit = !!this.options.apply_auto_limit;
if (!isArray(this.options.parameters)) { if (!isArray(this.options.parameters)) {
this.options.parameters = []; this.options.parameters = [];
@@ -400,25 +402,10 @@ QueryService.newQuery = function newQuery() {
name: "New Query", name: "New Query",
schedule: null, schedule: null,
user: currentUser, user: currentUser,
options: {}, options: { apply_auto_limit: localOptions.get("applyAutoLimit", true) },
tags: [], tags: [],
can_edit: true, can_edit: true,
}); });
}; };
QueryService.format = function formatQuery(syntax, query) {
if (syntax === "json") {
try {
const formatted = JSON.stringify(JSON.parse(query), " ", 4);
return Promise.resolve(formatted);
} catch (err) {
return Promise.reject(String(err));
}
} else if (syntax === "sql") {
return axios.post("api/queries/format", { query }).then(data => data.query);
} else {
return Promise.reject("Query formatting is not supported for your data source syntax.");
}
};
extend(Query, QueryService); extend(Query, QueryService);

View File

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

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

View File

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

View File

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