@@ -15,9 +14,3 @@ export function PageHeader({ title }) {
PageHeader.propTypes = {
title: PropTypes.string.isRequired,
};
-
-export default function init(ngModule) {
- ngModule.component("pageHeader", react2angular(PageHeader));
-}
-
-init.init = true;
diff --git a/client/app/components/Paginator.jsx b/client/app/components/Paginator.jsx
index 96094f2851..99589e843c 100644
--- a/client/app/components/Paginator.jsx
+++ b/client/app/components/Paginator.jsx
@@ -1,9 +1,8 @@
import React from "react";
import PropTypes from "prop-types";
-import { react2angular } from "react2angular";
import Pagination from "antd/lib/pagination";
-export function Paginator({ page, itemsPerPage, totalCount, onChange }) {
+export default function Paginator({ page, itemsPerPage, totalCount, onChange }) {
if (totalCount <= itemsPerPage) {
return null;
}
@@ -24,27 +23,3 @@ Paginator.propTypes = {
Paginator.defaultProps = {
onChange: () => {},
};
-
-export default function init(ngModule) {
- ngModule.component("paginatorImpl", react2angular(Paginator));
- ngModule.component("paginator", {
- template: `
-
`,
- bindings: {
- paginator: "<",
- },
- controller($scope) {
- this.onPageChanged = page => {
- this.paginator.setPage(page);
- $scope.$applyAsync();
- };
- },
- });
-}
-
-init.init = true;
diff --git a/client/app/components/ParameterApplyButton.jsx b/client/app/components/ParameterApplyButton.jsx
index 126977b144..d323bb2036 100644
--- a/client/app/components/ParameterApplyButton.jsx
+++ b/client/app/components/ParameterApplyButton.jsx
@@ -3,7 +3,7 @@ import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Badge from "antd/lib/badge";
import Tooltip from "antd/lib/tooltip";
-import { KeyboardShortcuts } from "@/services/keyboard-shortcuts";
+import KeyboardShortcuts from "@/services/KeyboardShortcuts";
function ParameterApplyButton({ paramCount, onClick }) {
// show spinner when count is empty so the fade out is consistent
diff --git a/client/app/components/ParameterValueInput.jsx b/client/app/components/ParameterValueInput.jsx
index 77bc1de1f4..f89ba1968e 100644
--- a/client/app/components/ParameterValueInput.jsx
+++ b/client/app/components/ParameterValueInput.jsx
@@ -1,3 +1,4 @@
+import { isEqual } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Select from "antd/lib/select";
@@ -5,8 +6,7 @@ import Input from "antd/lib/input";
import InputNumber from "antd/lib/input-number";
import DateParameter from "@/components/dynamic-parameters/DateParameter";
import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParameter";
-import { isEqual } from "lodash";
-import { QueryBasedParameterInput } from "./QueryBasedParameterInput";
+import QueryBasedParameterInput from "./QueryBasedParameterInput";
import "./ParameterValueInput.less";
diff --git a/client/app/components/Parameters.jsx b/client/app/components/Parameters.jsx
index 22dfe5765a..a82645c018 100644
--- a/client/app/components/Parameters.jsx
+++ b/client/app/components/Parameters.jsx
@@ -1,14 +1,13 @@
+import { size, filter, forEach, extend } from "lodash";
import React from "react";
import PropTypes from "prop-types";
-import { size, filter, forEach, extend } from "lodash";
-import { react2angular } from "react2angular";
import { SortableContainer, SortableElement, DragHandle } from "@/components/sortable";
import { $location } from "@/services/ng";
import { Parameter } from "@/services/parameters";
import ParameterApplyButton from "@/components/ParameterApplyButton";
import ParameterValueInput from "@/components/ParameterValueInput";
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
-import { toHuman } from "@/filters";
+import { toHuman } from "@/lib/utils";
import "./Parameters.less";
@@ -21,7 +20,7 @@ function updateUrl(parameters) {
$location.search(params);
}
-export class Parameters extends React.Component {
+export default class Parameters extends React.Component {
static propTypes = {
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
editable: PropTypes.bool,
@@ -51,11 +50,13 @@ export class Parameters extends React.Component {
componentDidUpdate = prevProps => {
const { parameters, disableUrlUpdate } = this.props;
- if (prevProps.parameters !== parameters) {
+ const parametersChanged = prevProps.parameters !== parameters;
+ const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate;
+ if (parametersChanged) {
this.setState({ parameters });
- if (!disableUrlUpdate) {
- updateUrl(parameters);
- }
+ }
+ if ((parametersChanged || disableUrlUpdateChanged) && !disableUrlUpdate) {
+ updateUrl(parameters);
}
};
@@ -174,9 +175,3 @@ export class Parameters extends React.Component {
);
}
}
-
-export default function init(ngModule) {
- ngModule.component("parameters", react2angular(Parameters));
-}
-
-init.init = true;
diff --git a/client/app/components/permissions-editor/PermissionsEditorDialog.jsx b/client/app/components/PermissionsEditorDialog/index.jsx
similarity index 98%
rename from client/app/components/permissions-editor/PermissionsEditorDialog.jsx
rename to client/app/components/PermissionsEditorDialog/index.jsx
index 9d897681f3..58d746745d 100644
--- a/client/app/components/permissions-editor/PermissionsEditorDialog.jsx
+++ b/client/app/components/PermissionsEditorDialog/index.jsx
@@ -9,13 +9,13 @@ import Tag from "antd/lib/tag";
import Tooltip from "antd/lib/tooltip";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { $http } from "@/services/ng";
-import { toHuman } from "@/filters";
+import { toHuman } from "@/lib/utils";
import HelpTrigger from "@/components/HelpTrigger";
import { UserPreviewCard } from "@/components/PreviewCard";
import notification from "@/services/notification";
import { User } from "@/services/user";
-import "./PermissionsEditorDialog.less";
+import "./index.less";
const { Option } = Select;
const DEBOUNCE_SEARCH_DURATION = 200;
diff --git a/client/app/components/permissions-editor/PermissionsEditorDialog.less b/client/app/components/PermissionsEditorDialog/index.less
similarity index 100%
rename from client/app/components/permissions-editor/PermissionsEditorDialog.less
rename to client/app/components/PermissionsEditorDialog/index.less
diff --git a/client/app/components/QueryBasedParameterInput.jsx b/client/app/components/QueryBasedParameterInput.jsx
index bdfa5dd424..29b2847540 100644
--- a/client/app/components/QueryBasedParameterInput.jsx
+++ b/client/app/components/QueryBasedParameterInput.jsx
@@ -1,12 +1,11 @@
import { find, isArray, map, intersection, isEqual } from "lodash";
import React from "react";
import PropTypes from "prop-types";
-import { react2angular } from "react2angular";
import Select from "antd/lib/select";
const { Option } = Select;
-export class QueryBasedParameterInput extends React.Component {
+export default class QueryBasedParameterInput extends React.Component {
static propTypes = {
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
@@ -107,9 +106,3 @@ export class QueryBasedParameterInput extends React.Component {
);
}
}
-
-export default function init(ngModule) {
- ngModule.component("queryBasedParameterInput", react2angular(QueryBasedParameterInput));
-}
-
-init.init = true;
diff --git a/client/app/components/QuerySelector.jsx b/client/app/components/QuerySelector.jsx
index 8f3a7bf301..d7141bf8f0 100644
--- a/client/app/components/QuerySelector.jsx
+++ b/client/app/components/QuerySelector.jsx
@@ -1,8 +1,7 @@
+import { find } from "lodash";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
-import { react2angular } from "react2angular";
-import { find } from "lodash";
import Input from "antd/lib/input";
import Select from "antd/lib/select";
import { Query } from "@/services/query";
@@ -24,7 +23,7 @@ function search(term) {
return Query.query({ q: term }).$promise.then(({ results }) => Promise.resolve(results));
}
-export function QuerySelector(props) {
+export default function QuerySelector(props) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedQuery, setSelectedQuery] = useState();
const [doSearch, searchResults, searching] = useSearchResults(search, { initialResults: [] });
@@ -157,9 +156,3 @@ QuerySelector.defaultProps = {
className: null,
disabled: false,
};
-
-export default function init(ngModule) {
- ngModule.component("querySelector", react2angular(QuerySelector));
-}
-
-init.init = true;
diff --git a/client/app/components/Resizable/index.jsx b/client/app/components/Resizable/index.jsx
new file mode 100644
index 0000000000..b0b2aaa5aa
--- /dev/null
+++ b/client/app/components/Resizable/index.jsx
@@ -0,0 +1,163 @@
+import d3 from "d3";
+import React, { useRef, useMemo, useCallback, useState, useEffect } from "react";
+import PropTypes from "prop-types";
+import { Resizable as ReactResizable } from "react-resizable";
+import KeyboardShortcuts from "@/services/KeyboardShortcuts";
+
+import "./index.less";
+
+export default function Resizable({ toggleShortcut, direction, sizeAttribute, children }) {
+ const [size, setSize] = useState(0);
+ const elementRef = useRef();
+ const wasUsingTouchEventsRef = useRef(false);
+ const wasResizedRef = useRef(false);
+
+ const sizeProp = direction === "horizontal" ? "width" : "height";
+ sizeAttribute = sizeAttribute || sizeProp;
+
+ const getElementSize = useCallback(() => {
+ if (!elementRef.current) {
+ return 0;
+ }
+ return Math.floor(elementRef.current.getBoundingClientRect()[sizeProp]);
+ }, [sizeProp]);
+
+ const savedSize = useRef(null);
+ const toggle = useCallback(() => {
+ if (!elementRef.current) {
+ return;
+ }
+
+ const element = d3.select(elementRef.current);
+ let targetSize;
+ if (savedSize.current === null) {
+ targetSize = "0px";
+ savedSize.current = `${getElementSize()}px`;
+ } else {
+ targetSize = savedSize.current;
+ savedSize.current = null;
+ }
+
+ element
+ .style(sizeAttribute, savedSize.current || "0px")
+ .transition()
+ .duration(200)
+ .ease("swing")
+ .style(sizeAttribute, targetSize);
+
+ // update state to new element's size
+ setSize(parseInt(targetSize) || 0);
+ }, [getElementSize, sizeAttribute]);
+
+ const resizeHandle = useMemo(
+ () => (
+
{
+ // On desktops resize uses `mousedown`/`mousemove`/`mouseup` events, and there is a conflict
+ // with this `click` handler: after user releases mouse - this handler will be executed.
+ // So we use `wasResized` flag to check if there was actual resize or user just pressed and released
+ // left mouse button (see also resize event handlers where ths flag is set).
+ // On mobile devices `touchstart`/`touchend` events wll be used, so it's safe to just execute this handler.
+ // To detect which set of events was actually used during particular resize operation, we pass
+ // `onMouseDown` handler to draggable core and check event type there (see also that handler's code).
+ if (wasUsingTouchEventsRef.current || !wasResizedRef.current) {
+ toggle();
+ }
+ wasUsingTouchEventsRef.current = false;
+ wasResizedRef.current = false;
+ }}
+ />
+ ),
+ [direction, toggle]
+ );
+
+ useEffect(() => {
+ if (toggleShortcut) {
+ const shortcuts = {
+ [toggleShortcut]: toggle,
+ };
+
+ KeyboardShortcuts.bind(shortcuts);
+ return () => {
+ KeyboardShortcuts.unbind(shortcuts);
+ };
+ }
+ }, [toggleShortcut, toggle]);
+
+ const resizeEventHandlers = useMemo(
+ () => ({
+ onResizeStart: () => {
+ // use element's size as initial value (it will also check constraints set in CSS)
+ // updated here and in `draggableCore::onMouseDown` handler to ensure that right value will be used
+ setSize(getElementSize());
+ },
+ onResize: (unused, data) => {
+ // update element directly for better UI responsiveness
+ d3.select(elementRef.current).style(sizeAttribute, `${data.size[sizeProp]}px`);
+ setSize(data.size[sizeProp]);
+ wasResizedRef.current = true;
+ },
+ onResizeStop: () => {
+ if (wasResizedRef.current) {
+ savedSize.current = null;
+ }
+ },
+ }),
+ [sizeProp, getElementSize, sizeAttribute]
+ );
+
+ const draggableCoreOptions = useMemo(
+ () => ({
+ onMouseDown: e => {
+ // In some cases this handler is executed twice during the same resize operation - first time
+ // with `touchstart` event and second time with `mousedown` (probably emulated by browser).
+ // Therefore we set the flag only when we receive `touchstart` because in ths case it's definitely
+ // mobile browser (desktop browsers will also send `mousedown` but never `touchstart`).
+ if (e.type === "touchstart") {
+ wasUsingTouchEventsRef.current = true;
+ }
+
+ // use element's size as initial value (it will also check constraints set in CSS)
+ // updated here and in `onResizeStart` handler to ensure that right value will be used
+ setSize(getElementSize());
+ },
+ }),
+ [getElementSize]
+ );
+
+ if (!children) {
+ return null;
+ }
+
+ children = React.createElement(children.type, { ...children.props, ref: elementRef });
+
+ return (
+
+ {children}
+
+ );
+}
+
+Resizable.propTypes = {
+ direction: PropTypes.oneOf(["horizontal", "vertical"]),
+ sizeAttribute: PropTypes.string,
+ toggleShortcut: PropTypes.string,
+ children: PropTypes.element,
+};
+
+Resizable.defaultProps = {
+ direction: "horizontal",
+ sizeAttribute: null, // "width"/"height" - depending on `direction`
+ toggleShortcut: null,
+ children: null,
+};
diff --git a/client/app/components/Resizable/index.less b/client/app/components/Resizable/index.less
new file mode 100644
index 0000000000..f8ea81a732
--- /dev/null
+++ b/client/app/components/Resizable/index.less
@@ -0,0 +1,57 @@
+@import (reference, less) "~@/assets/less/inc/variables.less";
+
+.resizable-component.react-resizable {
+ position: relative;
+
+ .react-resizable-handle {
+ position: absolute;
+ background: #fff;
+ margin: 0;
+ padding: 0;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover,
+ &:active {
+ background: mix(@redash-gray, #fff, 6%);
+ }
+
+ &.react-resizable-handle-horizontal {
+ cursor: col-resize;
+ width: 10px;
+ height: auto;
+ right: 0;
+ top: 0;
+ bottom: 0;
+
+ &:before {
+ content: "";
+ display: inline-block;
+ width: 3px;
+ height: 25px;
+ border-left: 1px solid #ccc;
+ border-right: 1px solid #ccc;
+ }
+ }
+
+ &.react-resizable-handle-vertical {
+ cursor: row-resize;
+ width: auto;
+ height: 10px;
+ left: 0;
+ right: 0;
+ bottom: 0;
+
+ &:before {
+ content: "";
+ display: inline-block;
+ width: 25px;
+ height: 3px;
+ border-top: 1px solid #ccc;
+ border-bottom: 1px solid #ccc;
+ }
+ }
+ }
+}
diff --git a/client/app/components/SettingsWrapper.jsx b/client/app/components/SettingsWrapper.jsx
index ffe31772be..1af6d6899a 100644
--- a/client/app/components/SettingsWrapper.jsx
+++ b/client/app/components/SettingsWrapper.jsx
@@ -1,6 +1,6 @@
import React from "react";
import Menu from "antd/lib/menu";
-import { PageHeader } from "@/components/PageHeader";
+import PageHeader from "@/components/PageHeader";
import { $location } from "@/services/ng";
import settingsMenu from "@/services/settingsMenu";
diff --git a/client/app/components/SortIcon.jsx b/client/app/components/SortIcon.jsx
deleted file mode 100644
index 8946b99235..0000000000
--- a/client/app/components/SortIcon.jsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import React from "react";
-import PropTypes from "prop-types";
-import { react2angular } from "react2angular";
-
-export function SortIcon({ column, sortColumn, reverse }) {
- if (column !== sortColumn) {
- return null;
- }
-
- return (
-
-
-
- );
-}
-
-SortIcon.propTypes = {
- column: PropTypes.string,
- sortColumn: PropTypes.string,
- reverse: PropTypes.bool,
-};
-
-SortIcon.defaultProps = {
- column: null,
- sortColumn: null,
- reverse: false,
-};
-
-export default function init(ngModule) {
- ngModule.component("sortIcon", react2angular(SortIcon));
-}
-
-init.init = true;
diff --git a/client/app/components/TagsList.jsx b/client/app/components/TagsList.jsx
index 8cb3247e26..3a09e9221f 100644
--- a/client/app/components/TagsList.jsx
+++ b/client/app/components/TagsList.jsx
@@ -1,14 +1,13 @@
import { map } from "lodash";
import React from "react";
import PropTypes from "prop-types";
-import { react2angular } from "react2angular";
import Badge from "antd/lib/badge";
import Menu from "antd/lib/menu";
import getTags from "@/services/getTags";
import "./TagsList.less";
-export class TagsList extends React.Component {
+export default class TagsList extends React.Component {
static propTypes = {
tagsUrl: PropTypes.string.isRequired,
onUpdate: PropTypes.func,
@@ -81,9 +80,3 @@ export class TagsList extends React.Component {
return null;
}
}
-
-export default function init(ngModule) {
- ngModule.component("tagsList", react2angular(TagsList));
-}
-
-init.init = true;
diff --git a/client/app/components/TimeAgo.jsx b/client/app/components/TimeAgo.jsx
index 6cdc8037f2..8b836c2d1d 100644
--- a/client/app/components/TimeAgo.jsx
+++ b/client/app/components/TimeAgo.jsx
@@ -1,7 +1,6 @@
import moment from "moment";
import { isNil } from "lodash";
import React, { useEffect } from "react";
-import ReactDOM from "react-dom";
import PropTypes from "prop-types";
import { Moment } from "@/components/proptypes";
import { clientConfig } from "@/services/auth";
@@ -13,7 +12,7 @@ function toMoment(value) {
return value && value.isValid() ? value : null;
}
-export function TimeAgo({ date, placeholder, autoUpdate }) {
+export default function TimeAgo({ date, placeholder, autoUpdate }) {
const startDate = toMoment(date);
const value = startDate ? startDate.fromNow() : placeholder;
@@ -49,36 +48,3 @@ TimeAgo.defaultProps = {
placeholder: "",
autoUpdate: true,
};
-
-export default function init(ngModule) {
- ngModule.directive("amTimeAgo", () => ({
- link($scope, $element, attr) {
- const modelName = attr.amTimeAgo;
- $scope.$watch(modelName, value => {
- ReactDOM.render(, $element[0]);
- });
-
- $scope.$on("$destroy", () => {
- ReactDOM.unmountComponentAtNode($element[0]);
- });
- },
- }));
-
- ngModule.component("rdTimeAgo", {
- bindings: {
- value: "=",
- },
- controller($scope, $element) {
- $scope.$watch("$ctrl.value", () => {
- // Initial render will occur here as well
- ReactDOM.render(, $element[0]);
- });
-
- $scope.$on("$destroy", () => {
- ReactDOM.unmountComponentAtNode($element[0]);
- });
- },
- });
-}
-
-init.init = true;
diff --git a/client/app/components/Timer.jsx b/client/app/components/Timer.jsx
index 6799a73c00..6abc76c0c0 100644
--- a/client/app/components/Timer.jsx
+++ b/client/app/components/Timer.jsx
@@ -1,11 +1,10 @@
import React, { useMemo, useEffect } from "react";
import moment from "moment";
import PropTypes from "prop-types";
-import { react2angular } from "react2angular";
import { Moment } from "@/components/proptypes";
import useForceUpdate from "@/lib/hooks/useForceUpdate";
-export function Timer({ from }) {
+export default function Timer({ from }) {
const startTime = useMemo(() => moment(from).valueOf(), [from]);
const forceUpdate = useForceUpdate();
@@ -27,9 +26,3 @@ Timer.propTypes = {
Timer.defaultProps = {
from: null,
};
-
-export default function init(ngModule) {
- ngModule.component("rdTimer", react2angular(Timer));
-}
-
-init.init = true;
diff --git a/client/app/components/admin/Layout.jsx b/client/app/components/admin/Layout.jsx
index 37ec563a49..f16a734842 100644
--- a/client/app/components/admin/Layout.jsx
+++ b/client/app/components/admin/Layout.jsx
@@ -1,7 +1,7 @@
import React from "react";
import PropTypes from "prop-types";
import Tabs from "antd/lib/tabs";
-import { PageHeader } from "@/components/PageHeader";
+import PageHeader from "@/components/PageHeader";
import "./layout.less";
diff --git a/client/app/components/admin/StatusBlock.jsx b/client/app/components/admin/StatusBlock.jsx
index 236a3f0eb8..4c98c848ed 100644
--- a/client/app/components/admin/StatusBlock.jsx
+++ b/client/app/components/admin/StatusBlock.jsx
@@ -5,9 +5,9 @@ import React from "react";
import List from "antd/lib/list";
import Card from "antd/lib/card";
-import { TimeAgo } from "@/components/TimeAgo";
+import TimeAgo from "@/components/TimeAgo";
-import { toHuman, prettySize } from "@/filters";
+import { toHuman, prettySize } from "@/lib/utils";
export function General({ info }) {
info = toPairs(info);
diff --git a/client/app/components/app-header/AppHeader.jsx b/client/app/components/app-header/AppHeader.jsx
index 76a9c9d602..16d4fb210e 100644
--- a/client/app/components/app-header/AppHeader.jsx
+++ b/client/app/components/app-header/AppHeader.jsx
@@ -1,7 +1,6 @@
/* eslint-disable no-template-curly-in-string */
import React, { useRef } from "react";
-import { react2angular } from "react2angular";
import Dropdown from "antd/lib/dropdown";
import Button from "antd/lib/button";
@@ -250,7 +249,7 @@ function MobileNavbar() {
);
}
-export function AppHeader() {
+export default function AppHeader() {
return (
);
}
-
-export default function init(ngModule) {
- ngModule.component("appHeader", react2angular(AppHeader));
-}
-
-init.init = true;
diff --git a/client/app/components/app-view/index.js b/client/app/components/app-view/index.js
index 2cbbfee3d1..3b85ffaa9a 100644
--- a/client/app/components/app-view/index.js
+++ b/client/app/components/app-view/index.js
@@ -1,4 +1,6 @@
import debug from "debug";
+import { react2angular } from "react2angular";
+import AppHeader from "@/components/app-header/AppHeader";
import PromiseRejectionError from "@/lib/promise-rejection-error";
import { ErrorHandler } from "./error-handler";
import template from "./template.html";
@@ -62,6 +64,9 @@ class AppViewComponent {
$rootScope.$on("$routeChangeSuccess", (event, route) => {
const $$route = route.$$route || { authenticated: true };
this.applyLayout($$route);
+ if (route.title) {
+ document.title = route.title;
+ }
});
$rootScope.$on("$routeChangeError", (event, current, previous, rejection) => {
@@ -86,10 +91,10 @@ export default function init(ngModule) {
}
);
+ ngModule.component("appHeader", react2angular(AppHeader));
+
ngModule.component("appView", {
template,
controller: AppViewComponent,
});
}
-
-init.init = true;
diff --git a/client/app/components/cancel-query-button/index.js b/client/app/components/cancel-query-button/index.js
deleted file mode 100644
index bca826e677..0000000000
--- a/client/app/components/cancel-query-button/index.js
+++ /dev/null
@@ -1,34 +0,0 @@
-function cancelQueryButton() {
- return {
- restrict: "E",
- scope: {
- queryId: "=",
- taskId: "=",
- },
- transclude: true,
- template:
- '',
- replace: true,
- controller($scope, $http, currentUser, Events) {
- $scope.inProgress = false;
-
- $scope.cancelExecution = () => {
- $http.delete(`api/jobs/${$scope.taskId}`).success(() => {});
-
- let queryId = $scope.queryId;
- if ($scope.queryId === "adhoc") {
- queryId = null;
- }
-
- Events.record("cancel_execute", "query", queryId, { admin: true });
- $scope.inProgress = true;
- };
- },
- };
-}
-
-export default function init(ngModule) {
- ngModule.directive("cancelQueryButton", cancelQueryButton);
-}
-
-init.init = true;
diff --git a/client/app/components/color-box.less b/client/app/components/color-box.less
deleted file mode 100644
index d1bac3afbf..0000000000
--- a/client/app/components/color-box.less
+++ /dev/null
@@ -1,10 +0,0 @@
-// ANGULAR_REMOVE_ME
-color-box {
- vertical-align: text-bottom;
- display: inline-block;
- margin-right: 5px;
-
- & ~ span {
- vertical-align: bottom;
- }
-}
diff --git a/client/app/components/dashboards/AddWidgetDialog.jsx b/client/app/components/dashboards/AddWidgetDialog.jsx
index b612850fe5..e61bd81949 100644
--- a/client/app/components/dashboards/AddWidgetDialog.jsx
+++ b/client/app/components/dashboards/AddWidgetDialog.jsx
@@ -5,7 +5,7 @@ import Select from "antd/lib/select";
import Modal from "antd/lib/modal";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { MappingType, ParameterMappingListInput } from "@/components/ParameterMappingInput";
-import { QuerySelector } from "@/components/QuerySelector";
+import QuerySelector from "@/components/QuerySelector";
import notification from "@/services/notification";
diff --git a/client/app/components/dashboards/ExpandedWidgetDialog.jsx b/client/app/components/dashboards/ExpandedWidgetDialog.jsx
index 7bc68a794f..b705e58fb4 100644
--- a/client/app/components/dashboards/ExpandedWidgetDialog.jsx
+++ b/client/app/components/dashboards/ExpandedWidgetDialog.jsx
@@ -2,7 +2,7 @@ import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
-import { VisualizationRenderer } from "@/visualizations/VisualizationRenderer";
+import VisualizationRenderer from "@/visualizations/VisualizationRenderer";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import VisualizationName from "@/visualizations/VisualizationName";
diff --git a/client/app/components/dashboards/dashboard-grid.less b/client/app/components/dashboards/dashboard-grid.less
index bc8e79a548..95f23c7324 100644
--- a/client/app/components/dashboards/dashboard-grid.less
+++ b/client/app/components/dashboards/dashboard-grid.less
@@ -36,7 +36,8 @@
&.editing-mode {
/* Y axis lines */
- background: linear-gradient(to right, transparent, transparent 1px, #F6F8F9 1px, #F6F8F9), linear-gradient(to bottom, #B3BABF, #B3BABF 1px, transparent 1px, transparent);
+ background: linear-gradient(to right, transparent, transparent 1px, #f6f8f9 1px, #f6f8f9),
+ linear-gradient(to bottom, #b3babf, #b3babf 1px, transparent 1px, transparent);
background-size: 5px 50px;
background-position-y: -8px;
@@ -48,7 +49,8 @@
left: 0;
bottom: 85px;
right: 15px;
- background: linear-gradient(to bottom, transparent, transparent 2px, #F6F8F9 2px, #F6F8F9 5px), linear-gradient(to left, #B3BABF, #B3BABF 1px, transparent 1px, transparent);
+ background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
+ linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
background-size: calc((100vw - 15px) / 6) 5px;
background-position: -7px 1px;
}
@@ -123,11 +125,10 @@
// react-grid-layout overrides
.react-grid-item {
-
// placeholder color
&.react-grid-placeholder {
border-radius: 3px;
- background-color: #E0E6EB;
+ background-color: #e0e6eb;
opacity: 0.5;
}
@@ -142,10 +143,13 @@
}
// resize handle size
- & > .react-resizable-handle::after {
- width: 11px;
- height: 11px;
- right: 5px;
- bottom: 5px;
+ & > .react-resizable-handle {
+ background: none;
+ &:after {
+ width: 11px;
+ height: 11px;
+ right: 5px;
+ bottom: 5px;
+ }
}
}
diff --git a/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx b/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx
index ef10c720a7..d19ba2aac2 100644
--- a/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx
+++ b/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx
@@ -6,17 +6,17 @@ import cx from "classnames";
import Menu from "antd/lib/menu";
import { currentUser } from "@/services/auth";
import recordEvent from "@/services/recordEvent";
-import { formatDateTime } from "@/filters/datetime";
+import { formatDateTime } from "@/lib/utils";
import HtmlContent from "@/components/HtmlContent";
-import { Parameters } from "@/components/Parameters";
-import { TimeAgo } from "@/components/TimeAgo";
-import { Timer } from "@/components/Timer";
+import Parameters from "@/components/Parameters";
+import TimeAgo from "@/components/TimeAgo";
+import Timer from "@/components/Timer";
import { Moment } from "@/components/proptypes";
import QueryLink from "@/components/QueryLink";
import { FiltersType } from "@/components/Filters";
import ExpandedWidgetDialog from "@/components/dashboards/ExpandedWidgetDialog";
import EditParameterMappingsDialog from "@/components/dashboards/EditParameterMappingsDialog";
-import { VisualizationRenderer } from "@/visualizations/VisualizationRenderer";
+import VisualizationRenderer from "@/visualizations/VisualizationRenderer";
import Widget from "./Widget";
function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParametersEdit }) {
diff --git a/client/app/components/dynamic-form/DynamicForm.jsx b/client/app/components/dynamic-form/DynamicForm.jsx
index d2c3971150..a43e03fdb8 100644
--- a/client/app/components/dynamic-form/DynamicForm.jsx
+++ b/client/app/components/dynamic-form/DynamicForm.jsx
@@ -13,7 +13,7 @@ import Select from "antd/lib/select";
import notification from "@/services/notification";
import Collapse from "@/components/Collapse";
import AceEditorInput from "@/components/AceEditorInput";
-import { toHuman } from "@/filters";
+import { toHuman } from "@/lib/utils";
import { Field, Action, AntdForm } from "../proptypes";
import helper from "./dynamicFormHelper";
diff --git a/client/app/components/groups/GroupName.jsx b/client/app/components/groups/GroupName.jsx
index e50f129e26..de137364a1 100644
--- a/client/app/components/groups/GroupName.jsx
+++ b/client/app/components/groups/GroupName.jsx
@@ -1,6 +1,6 @@
import React from "react";
import PropTypes from "prop-types";
-import { EditInPlace } from "@/components/EditInPlace";
+import EditInPlace from "@/components/EditInPlace";
import { currentUser } from "@/services/auth";
function updateGroupName(group, name, onChange) {
diff --git a/client/app/components/items-list/components/ItemsTable.jsx b/client/app/components/items-list/components/ItemsTable.jsx
index 231c934a3b..ac95a7d65d 100644
--- a/client/app/components/items-list/components/ItemsTable.jsx
+++ b/client/app/components/items-list/components/ItemsTable.jsx
@@ -3,10 +3,9 @@ import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import Table from "antd/lib/table";
-import { FavoritesControl } from "@/components/FavoritesControl";
-import { TimeAgo } from "@/components/TimeAgo";
-import { durationHumanize } from "@/filters";
-import { formatDate, formatDateTime } from "@/filters/datetime";
+import FavoritesControl from "@/components/FavoritesControl";
+import TimeAgo from "@/components/TimeAgo";
+import { durationHumanize, formatDate, formatDateTime } from "@/lib/utils";
// `this` refers to previous function in the chain (`Columns.***`).
// Adds `sorter: true` field to column definition
diff --git a/client/app/components/items-list/components/Sidebar.jsx b/client/app/components/items-list/components/Sidebar.jsx
index b834be3dbf..93e54be3b4 100644
--- a/client/app/components/items-list/components/Sidebar.jsx
+++ b/client/app/components/items-list/components/Sidebar.jsx
@@ -4,7 +4,7 @@ import PropTypes from "prop-types";
import Input from "antd/lib/input";
import AntdMenu from "antd/lib/menu";
import Select from "antd/lib/select";
-import { TagsList } from "@/components/TagsList";
+import TagsList from "@/components/TagsList";
/*
SearchInput
diff --git a/client/app/components/overlay.js b/client/app/components/overlay.js
deleted file mode 100644
index c1eb54b54c..0000000000
--- a/client/app/components/overlay.js
+++ /dev/null
@@ -1,18 +0,0 @@
-const Overlay = {
- template: `
-
- `,
- transclude: true,
-};
-
-export default function init(ngModule) {
- ngModule.component("overlay", Overlay);
-}
-
-init.init = true;
diff --git a/client/app/components/queries/ApiKeyDialog/index.jsx b/client/app/components/queries/ApiKeyDialog/index.jsx
new file mode 100644
index 0000000000..96641a7051
--- /dev/null
+++ b/client/app/components/queries/ApiKeyDialog/index.jsx
@@ -0,0 +1,79 @@
+import { extend } from "lodash";
+import React, { useMemo, useState, useCallback } from "react";
+import PropTypes from "prop-types";
+import Modal from "antd/lib/modal";
+import Input from "antd/lib/input";
+import Button from "antd/lib/button";
+import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
+import CodeBlock from "@/components/CodeBlock";
+import { $http } from "@/services/ng";
+import { clientConfig } from "@/services/auth";
+import notification from "@/services/notification";
+
+import "./index.less";
+
+function ApiKeyDialog({ dialog, ...props }) {
+ const [query, setQuery] = useState(props.query);
+ const [updatingApiKey, setUpdatingApiKey] = useState(false);
+
+ const regenerateQueryApiKey = useCallback(() => {
+ setUpdatingApiKey(true);
+ $http
+ .post(`api/queries/${query.id}/regenerate_api_key`)
+ .success(data => {
+ setUpdatingApiKey(false);
+ setQuery(extend(query.clone(), { api_key: data.api_key }));
+ })
+ .error(() => {
+ setUpdatingApiKey(false);
+ notification.error("Failed to update API key");
+ });
+ }, [query]);
+
+ const { csvUrl, jsonUrl } = useMemo(
+ () => ({
+ csvUrl: `${clientConfig.basePath}api/queries/${query.id}/results.csv?api_key=${query.api_key}`,
+ jsonUrl: `${clientConfig.basePath}api/queries/${query.id}/results.json?api_key=${query.api_key}`,
+ }),
+ [query.id, query.api_key]
+ );
+
+ return (
+ dialog.close(query)}>Close}>
+
+
API Key
+
+
+
+ {query.can_edit && (
+
+ )}
+
+
+
+
Example API Calls:
+
+
+ {csvUrl}
+
+
+
+ {jsonUrl}
+
+
+
+ );
+}
+
+ApiKeyDialog.propTypes = {
+ dialog: DialogPropType.isRequired,
+ query: PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ api_key: PropTypes.string,
+ can_edit: PropTypes.bool,
+ }).isRequired,
+};
+
+export default wrapDialog(ApiKeyDialog);
diff --git a/client/app/components/queries/ApiKeyDialog/index.less b/client/app/components/queries/ApiKeyDialog/index.less
new file mode 100644
index 0000000000..4229a1a7e6
--- /dev/null
+++ b/client/app/components/queries/ApiKeyDialog/index.less
@@ -0,0 +1,17 @@
+.query-api-key-dialog-wrapper {
+ .ant-input-group.ant-input-group-compact {
+ display: flex;
+ flex-wrap: nowrap;
+
+ .ant-input {
+ flex-grow: 1;
+ flex-shrink: 1;
+ }
+
+ .ant-btn {
+ flex-grow: 0;
+ flex-shrink: 0;
+ height: auto;
+ }
+ }
+}
diff --git a/client/app/components/queries/QueryEditor/QueryEditorComponent.jsx b/client/app/components/queries/QueryEditor/QueryEditorComponent.jsx
deleted file mode 100644
index c79ce8bc90..0000000000
--- a/client/app/components/queries/QueryEditor/QueryEditorComponent.jsx
+++ /dev/null
@@ -1,178 +0,0 @@
-import React, { useEffect, useMemo, useRef, useState, useCallback, useImperativeHandle } from "react";
-import PropTypes from "prop-types";
-import cx from "classnames";
-import { AceEditor, snippetsModule, updateSchemaCompleter } from "./ace";
-import resizeObserver from "@/services/resizeObserver";
-import { QuerySnippet } from "@/services/query-snippet";
-
-const editorProps = { $blockScrolling: Infinity };
-
-const QueryEditorComponent = React.forwardRef(function(
- { className, syntax, value, autocompleteEnabled, schema, onChange, onSelectionChange, ...props },
- ref
-) {
- const [container, setContainer] = useState(null);
- const editorRef = useRef(null);
-
- // For some reason, value for AceEditor should be managed in this way - otherwise it goes berserk when selecting text
- const [currentValue, setCurrentValue] = useState(value);
-
- useEffect(() => {
- setCurrentValue(value);
- }, [value]);
-
- const handleChange = useCallback(
- str => {
- setCurrentValue(str);
- onChange(str);
- },
- [onChange]
- );
-
- const editorOptions = useMemo(
- () => ({
- behavioursEnabled: true,
- enableSnippets: true,
- enableBasicAutocompletion: true,
- enableLiveAutocompletion: autocompleteEnabled,
- autoScrollEditorIntoView: true,
- }),
- [autocompleteEnabled]
- );
-
- useEffect(() => {
- if (editorRef.current) {
- const { editor } = editorRef.current;
- updateSchemaCompleter(editor.id, schema); // TODO: cleanup?
- }
- }, [schema]);
-
- useEffect(() => {
- function resize() {
- if (editorRef.current) {
- const { editor } = editorRef.current;
- editor.resize();
- }
- }
-
- if (container) {
- resize();
- const unwatch = resizeObserver(container, resize);
- return unwatch;
- }
- }, [container]);
-
- const handleSelectionChange = useCallback(
- selection => {
- const { editor } = editorRef.current;
- const rawSelectedQueryText = editor.session.doc.getTextRange(selection.getRange());
- const selectedQueryText = rawSelectedQueryText.length > 1 ? rawSelectedQueryText : null;
- onSelectionChange(selectedQueryText);
- },
- [onSelectionChange]
- );
-
- const initEditor = useCallback(editor => {
- // Release Cmd/Ctrl+L to the browser
- editor.commands.bindKey("Cmd+L", null);
- editor.commands.bindKey("Ctrl+P", null);
- editor.commands.bindKey("Ctrl+L", null);
-
- // Ignore Ctrl+P to open new parameter dialog
- editor.commands.bindKey({ win: "Ctrl+P", mac: null }, null);
- // Lineup only mac
- editor.commands.bindKey({ win: null, mac: "Ctrl+P" }, "golineup");
- editor.commands.bindKey({ win: "Ctrl+Shift+F", mac: "Cmd+Shift+F" }, () => console.log("formatQuery"));
-
- // Reset Completer in case dot is pressed
- editor.commands.on("afterExec", e => {
- if (e.command.name === "insertstring" && e.args === "." && editor.completer) {
- editor.completer.showPopup(editor);
- }
- });
-
- QuerySnippet.query(snippets => {
- const snippetManager = snippetsModule.snippetManager;
- const m = {
- snippetText: "",
- };
- m.snippets = snippetManager.parseSnippetFile(m.snippetText);
- snippets.forEach(snippet => {
- m.snippets.push(snippet.getSnippet());
- });
- snippetManager.register(m.snippets || [], m.scope);
- });
-
- editor.focus();
- }, []);
-
- useImperativeHandle(
- ref,
- () => ({
- paste: text => {
- if (editorRef.current) {
- const { editor } = editorRef.current;
- editor.session.doc.replace(editor.selection.getRange(), text);
- const range = editor.selection.getRange();
- onChange(editor.session.getValue());
- editor.selection.setRange(range);
- }
- },
- focus: () => {
- if (editorRef.current) {
- const { editor } = editorRef.current;
- editor.focus();
- }
- },
- }),
- [onChange]
- );
-
- return (
-
- );
-});
-
-QueryEditorComponent.propTypes = {
- className: PropTypes.string,
- syntax: PropTypes.string,
- value: PropTypes.string,
- autocompleteEnabled: PropTypes.bool,
- schema: PropTypes.arrayOf(
- PropTypes.shape({
- name: PropTypes.string.isRequired,
- size: PropTypes.number,
- columns: PropTypes.arrayOf(PropTypes.string).isRequired,
- })
- ),
- onChange: PropTypes.func,
- onSelectionChange: PropTypes.func,
-};
-
-QueryEditorComponent.defaultProps = {
- className: null,
- syntax: null,
- value: null,
- autocompleteEnabled: true,
- schema: [],
- onChange: () => {},
- onSelectionChange: () => {},
-};
-
-export default QueryEditorComponent;
diff --git a/client/app/components/queries/QueryEditor/QueryEditorControls.jsx b/client/app/components/queries/QueryEditor/QueryEditorControls.jsx
index 855a3d8869..6dd665f0e3 100644
--- a/client/app/components/queries/QueryEditor/QueryEditorControls.jsx
+++ b/client/app/components/queries/QueryEditor/QueryEditorControls.jsx
@@ -1,13 +1,37 @@
-import { map } from "lodash";
-import React from "react";
+import { isFunction, map, filter, fromPairs } from "lodash";
+import React, { useEffect } from "react";
import PropTypes from "prop-types";
import Tooltip from "antd/lib/tooltip";
import Button from "antd/lib/button";
import Select from "antd/lib/select";
+import KeyboardShortcuts, { humanReadableShortcut } from "@/services/KeyboardShortcuts";
import AutocompleteToggle from "./AutocompleteToggle";
import "./QueryEditorControls.less";
+function ButtonTooltip({ title, shortcut, ...props }) {
+ shortcut = humanReadableShortcut(shortcut, 1); // show only primary shortcut
+ title =
+ title && shortcut ? (
+
+ {title} ({shortcut})
+
+ ) : (
+ title || shortcut
+ );
+ return ;
+}
+
+ButtonTooltip.propTypes = {
+ title: PropTypes.node,
+ shortcut: PropTypes.string,
+};
+
+ButtonTooltip.defaultProps = {
+ title: null,
+ shortcut: null,
+};
+
export default function EditorControl({
addParameterButtonProps,
formatButtonProps,
@@ -16,20 +40,34 @@ export default function EditorControl({
autocompleteToggleProps,
dataSourceSelectorProps,
}) {
+ useEffect(() => {
+ const buttons = filter(
+ [addParameterButtonProps, formatButtonProps, saveButtonProps, executeButtonProps],
+ b => b.shortcut && !b.disabled && isFunction(b.onClick)
+ );
+ if (buttons.length > 0) {
+ const shortcuts = fromPairs(map(buttons, b => [b.shortcut, b.onClick]));
+ KeyboardShortcuts.bind(shortcuts);
+ return () => {
+ KeyboardShortcuts.unbind(shortcuts);
+ };
+ }
+ }, [addParameterButtonProps, formatButtonProps, saveButtonProps, executeButtonProps]);
+
return (
{addParameterButtonProps !== false && (
-
+
-
+
)}
{formatButtonProps !== false && (
-
+
{formatButtonProps.text}
-
+
)}
{autocompleteToggleProps !== false && (
)}
- {dataSourceSelectorProps === false &&
}
+ {dataSourceSelectorProps === false &&
}
{dataSourceSelectorProps !== false && (
);
@@ -96,6 +134,7 @@ const ButtonPropsPropType = PropTypes.oneOfType([
disabled: PropTypes.bool,
onClick: PropTypes.func,
text: PropTypes.node,
+ shortcut: PropTypes.string,
}),
]);
diff --git a/client/app/components/queries/QueryEditor/QueryEditorControls.less b/client/app/components/queries/QueryEditor/QueryEditorControls.less
index c80e75dc14..f81c152ae5 100644
--- a/client/app/components/queries/QueryEditor/QueryEditorControls.less
+++ b/client/app/components/queries/QueryEditor/QueryEditorControls.less
@@ -20,4 +20,9 @@
margin-left: 5px;
}
}
+
+ .query-editor-controls-spacer {
+ flex: 1 1 auto;
+ height: 35px; // same as Antd
);
-}
+});
QueryEditor.propTypes = {
- queryText: PropTypes.string.isRequired,
- schema: Schema,
- addNewParameter: PropTypes.func.isRequired,
- dataSources: PropTypes.arrayOf(DataSource),
- dataSource: DataSource,
- canEdit: PropTypes.bool.isRequired,
- isDirty: PropTypes.bool.isRequired,
- isQueryOwner: PropTypes.bool.isRequired,
- updateDataSource: PropTypes.func.isRequired,
- canExecuteQuery: PropTypes.bool.isRequired,
- executeQuery: PropTypes.func.isRequired,
- queryExecuting: PropTypes.bool.isRequired,
- saveQuery: PropTypes.func.isRequired,
- updateQuery: PropTypes.func.isRequired,
- updateSelectedQuery: PropTypes.func.isRequired,
- listenForEditorCommand: PropTypes.func.isRequired,
+ className: PropTypes.string,
+ syntax: PropTypes.string,
+ value: PropTypes.string,
+ autocompleteEnabled: PropTypes.bool,
+ schema: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ size: PropTypes.number,
+ columns: PropTypes.arrayOf(PropTypes.string).isRequired,
+ })
+ ),
+ onChange: PropTypes.func,
+ onSelectionChange: PropTypes.func,
};
QueryEditor.defaultProps = {
- schema: null,
- dataSource: {},
- dataSources: [],
+ className: null,
+ syntax: null,
+ value: null,
+ autocompleteEnabled: true,
+ schema: [],
+ onChange: () => {},
+ onSelectionChange: () => {},
};
-export default function init(ngModule) {
- ngModule.component("queryEditor", react2angular(QueryEditor));
-}
+QueryEditor.Controls = QueryEditorControls;
-init.init = true;
+export default QueryEditor;
diff --git a/client/app/components/queries/QueryEditor/index.less b/client/app/components/queries/QueryEditor/index.less
index beede5b719..a6ac187c69 100644
--- a/client/app/components/queries/QueryEditor/index.less
+++ b/client/app/components/queries/QueryEditor/index.less
@@ -1,36 +1,13 @@
-.editor__wrapper {
- padding: 15px;
- margin-bottom: 10px;
- height: 100%;
- display: flex;
- flex-direction: column;
- flex-wrap: nowrap;
-
- .editor__container {
- margin-bottom: 0;
- flex: 1 1 auto;
- position: relative;
-
- .ace_editor {
- position: absolute;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
- margin: 0;
- }
-
- &[data-executing] {
- .ace_marker-layer {
- .ace_selection {
- background-color: rgb(255, 210, 181);
- }
- }
- }
- }
-
- .query-editor-controls {
- flex: 0 0 auto;
- margin-top: 10px;
+.query-editor-container {
+ margin-bottom: 0;
+ position: relative;
+
+ .ace_editor {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ margin: 0;
}
}
diff --git a/client/app/components/queries/ScheduleDialog.jsx b/client/app/components/queries/ScheduleDialog.jsx
index 725e17d94d..adfadb5695 100644
--- a/client/app/components/queries/ScheduleDialog.jsx
+++ b/client/app/components/queries/ScheduleDialog.jsx
@@ -7,7 +7,7 @@ import Select from "antd/lib/select";
import Radio from "antd/lib/radio";
import { capitalize, clone, isEqual, omitBy, isNil } from "lodash";
import moment from "moment";
-import { secondsToInterval, durationHumanize, pluralize, IntervalEnum, localizeTime } from "@/filters";
+import { secondsToInterval, durationHumanize, pluralize, IntervalEnum, localizeTime } from "@/lib/utils";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { RefreshScheduleType, RefreshScheduleDefault, Moment } from "../proptypes";
diff --git a/client/app/components/queries/SchedulePhrase.jsx b/client/app/components/queries/SchedulePhrase.jsx
index f40c4f58c0..7054ffd793 100644
--- a/client/app/components/queries/SchedulePhrase.jsx
+++ b/client/app/components/queries/SchedulePhrase.jsx
@@ -1,22 +1,23 @@
-import { react2angular } from "react2angular";
import React from "react";
import PropTypes from "prop-types";
import Tooltip from "antd/lib/tooltip";
-import { localizeTime, durationHumanize } from "@/filters";
+import { localizeTime, durationHumanize } from "@/lib/utils";
import { RefreshScheduleType, RefreshScheduleDefault } from "../proptypes";
import "./ScheduleDialog.css";
-export class SchedulePhrase extends React.Component {
+export default class SchedulePhrase extends React.Component {
static propTypes = {
schedule: RefreshScheduleType,
isNew: PropTypes.bool.isRequired,
isLink: PropTypes.bool,
+ onClick: PropTypes.func,
};
static defaultProps = {
schedule: RefreshScheduleDefault,
isLink: false,
+ onClick: () => {},
};
get content() {
@@ -49,12 +50,12 @@ export class SchedulePhrase extends React.Component {
const [short, full] = this.content;
const content = full ?
{short} : short;
- return this.props.isLink ?
{content} : content;
+ return this.props.isLink ? (
+
+ {content}
+
+ ) : (
+ content
+ );
}
}
-
-export default function init(ngModule) {
- ngModule.component("schedulePhrase", react2angular(SchedulePhrase));
-}
-
-init.init = true;
diff --git a/client/app/components/queries/alert-unsaved-changes.js b/client/app/components/queries/alert-unsaved-changes.js
deleted file mode 100644
index ce0768c729..0000000000
--- a/client/app/components/queries/alert-unsaved-changes.js
+++ /dev/null
@@ -1,39 +0,0 @@
-function alertUnsavedChanges($window) {
- return {
- restrict: "E",
- replace: true,
- scope: {
- isDirty: "=",
- },
- link($scope) {
- const unloadMessage = "You will lose your changes if you leave";
- const confirmMessage = `${unloadMessage}\n\nAre you sure you want to leave this page?`;
- // store original handler (if any)
- const _onbeforeunload = $window.onbeforeunload;
-
- $window.onbeforeunload = function onbeforeunload() {
- return $scope.isDirty ? unloadMessage : null;
- };
-
- $scope.$on("$locationChangeStart", (event, next, current) => {
- if (next.split("?")[0] === current.split("?")[0] || next.split("#")[0] === current.split("#")[0]) {
- return;
- }
-
- if ($scope.isDirty && !$window.confirm(confirmMessage)) {
- event.preventDefault();
- }
- });
-
- $scope.$on("$destroy", () => {
- $window.onbeforeunload = _onbeforeunload;
- });
- },
- };
-}
-
-export default function init(ngModule) {
- ngModule.directive("alertUnsavedChanges", alertUnsavedChanges);
-}
-
-init.init = true;
diff --git a/client/app/components/queries/api-key-dialog.js b/client/app/components/queries/api-key-dialog.js
deleted file mode 100644
index e9b8d3f7f3..0000000000
--- a/client/app/components/queries/api-key-dialog.js
+++ /dev/null
@@ -1,59 +0,0 @@
-const ApiKeyDialog = {
- template: `
-
-
API Key
-
-
-
Example API Calls:
-
-
- Results in CSV format:
-
-
{{$ctrl.csvUrlBase + $ctrl.query.api_key}}
-
- Results in JSON format:
-
-
{{$ctrl.jsonUrlBase + $ctrl.query.api_key}}
-
-
`,
- controller($http, clientConfig, currentUser) {
- "ngInject";
-
- this.canEdit = currentUser.id === this.resolve.query.user.id || currentUser.hasPermission("admin");
- this.disableRegenerateApiKeyButton = false;
- this.query = this.resolve.query;
- this.csvUrlBase = `${clientConfig.basePath}api/queries/${this.resolve.query.id}/results.csv?api_key=`;
- this.jsonUrlBase = `${clientConfig.basePath}api/queries/${this.resolve.query.id}/results.json?api_key=`;
-
- this.regenerateQueryApiKey = () => {
- this.disableRegenerateApiKeyButton = true;
- $http
- .post(`api/queries/${this.resolve.query.id}/regenerate_api_key`)
- .success(data => {
- this.query.api_key = data.api_key;
- this.disableRegenerateApiKeyButton = false;
- })
- .error(() => {
- this.disableRegenerateApiKeyButton = false;
- });
- };
- },
- bindings: {
- resolve: "<",
- close: "&",
- dismiss: "&",
- },
-};
-
-export default function init(ngModule) {
- ngModule.component("apiKeyDialog", ApiKeyDialog);
-}
-
-init.init = true;
diff --git a/client/app/components/queries/schema-browser.html b/client/app/components/queries/schema-browser.html
deleted file mode 100644
index 6e3f518059..0000000000
--- a/client/app/components/queries/schema-browser.html
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- {{table.name}}
- ({{table.size}})
-
-
-
-
-
-
-
diff --git a/client/app/components/queries/schema-browser.js b/client/app/components/queries/schema-browser.js
deleted file mode 100644
index 9553338467..0000000000
--- a/client/app/components/queries/schema-browser.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import template from "./schema-browser.html";
-
-function SchemaBrowserCtrl($rootScope, $scope) {
- "ngInject";
-
- this.showTable = table => {
- table.collapsed = !table.collapsed;
- $scope.$broadcast("vsRepeatTrigger");
- };
-
- this.getSize = table => {
- let size = 22;
-
- if (!table.collapsed) {
- size += 18 * table.columns.length;
- }
-
- return size;
- };
-
- this.isEmpty = function isEmpty() {
- return this.schema === undefined || this.schema.length === 0;
- };
-
- this.itemSelected = ($event, hierarchy) => {
- $rootScope.$broadcast("query-editor.command", "paste", hierarchy.join("."));
- $event.preventDefault();
- $event.stopPropagation();
- };
-
- this.splitFilter = filter => {
- filter = filter.replace(/ {2}/g, " ");
- if (filter.includes(" ")) {
- const splitTheFilter = filter.split(" ");
- this.schemaFilterObject = { name: splitTheFilter[0], columns: splitTheFilter[1] };
- this.schemaFilterColumn = splitTheFilter[1];
- } else {
- this.schemaFilterObject = filter;
- this.schemaFilterColumn = "";
- }
- };
-}
-
-const SchemaBrowser = {
- bindings: {
- schema: "<",
- onRefresh: "&",
- },
- controller: SchemaBrowserCtrl,
- template,
-};
-
-export default function init(ngModule) {
- ngModule.component("schemaBrowser", SchemaBrowser);
-}
-
-init.init = true;
diff --git a/client/app/components/rd-tab/index.js b/client/app/components/rd-tab/index.js
deleted file mode 100644
index ba18ee6e31..0000000000
--- a/client/app/components/rd-tab/index.js
+++ /dev/null
@@ -1,29 +0,0 @@
-function rdTab($location) {
- return {
- restrict: "E",
- scope: {
- tabId: "@",
- name: "@",
- basePath: "=?",
- },
- transclude: true,
- template:
- '
{{name}}',
- replace: true,
- link(scope) {
- scope.basePath = scope.basePath || $location.path().substring(1);
- scope.$watch(
- () => scope.$parent.selectedTab,
- tab => {
- scope.selectedTab = tab;
- }
- );
- },
- };
-}
-
-export default function init(ngModule) {
- ngModule.directive("rdTab", rdTab);
-}
-
-init.init = true;
diff --git a/client/app/components/tab-nav/index.js b/client/app/components/tab-nav/index.js
deleted file mode 100644
index 270a9ffa9f..0000000000
--- a/client/app/components/tab-nav/index.js
+++ /dev/null
@@ -1,24 +0,0 @@
-function controller($location) {
- this.tabs.forEach(tab => {
- if (tab.isActive) {
- tab.active = tab.isActive($location.path());
- } else {
- tab.active = $location.path().startsWith(`/${tab.path}`);
- }
- });
-}
-
-export default function init(ngModule) {
- ngModule.component("tabNav", {
- template:
- '
",
- controller,
- bindings: {
- tabs: "<",
- },
- });
-}
-
-init.init = true;
diff --git a/client/app/components/tags-control/TagsControl.jsx b/client/app/components/tags-control/TagsControl.jsx
index 3cb8a98428..c75f8ad23b 100644
--- a/client/app/components/tags-control/TagsControl.jsx
+++ b/client/app/components/tags-control/TagsControl.jsx
@@ -1,7 +1,6 @@
-import { map, trim, extend } from "lodash";
+import { map, trim } from "lodash";
import React from "react";
import PropTypes from "prop-types";
-import { react2angular } from "react2angular";
import Tooltip from "antd/lib/tooltip";
import EditTagsDialog from "./EditTagsDialog";
@@ -74,22 +73,15 @@ function modelTagsControl({ archivedTooltip }) {
);
}
- // ANGULAR_REMOVE_ME `extend` needed just for `react2angular`, so remove it when `react2angular` no longer needed
- ModelTagsControl.propTypes = extend(
- {
- isDraft: PropTypes.bool,
- isArchived: PropTypes.bool,
- },
- TagsControl.propTypes
- );
+ ModelTagsControl.propTypes = {
+ isDraft: PropTypes.bool,
+ isArchived: PropTypes.bool,
+ };
- ModelTagsControl.defaultProps = extend(
- {
- isDraft: false,
- isArchived: false,
- },
- TagsControl.defaultProps
- );
+ ModelTagsControl.defaultProps = {
+ isDraft: false,
+ isArchived: false,
+ };
return ModelTagsControl;
}
@@ -101,9 +93,3 @@ export const QueryTagsControl = modelTagsControl({
export const DashboardTagsControl = modelTagsControl({
archivedTooltip: "This dashboard is archived and won't be listed in dashboards nor search results.",
});
-
-export default function init(ngModule) {
- ngModule.component("queryTagsControl", react2angular(QueryTagsControl));
-}
-
-init.init = true;
diff --git a/client/app/config/index.js b/client/app/config/index.js
index 66af01eb45..df58b595ef 100644
--- a/client/app/config/index.js
+++ b/client/app/config/index.js
@@ -10,20 +10,12 @@ import angular from "angular";
import ngSanitize from "angular-sanitize";
import ngRoute from "angular-route";
import ngResource from "angular-resource";
-import uiBootstrap from "angular-ui-bootstrap";
-import uiSelect from "ui-select";
-import vsRepeat from "angular-vs-repeat";
-import "brace";
-import "angular-resizable";
import { each, isFunction, extend } from "lodash";
+import initAppView from "@/components/app-view";
import DialogWrapper from "@/components/DialogWrapper";
import organizationStatus from "@/services/organizationStatus";
-import * as filters from "@/filters";
-import registerDirectives from "@/directives";
-import markdownFilter from "@/filters/markdown";
-import dateTimeFilter from "@/filters/datetime";
import "./antd-spinner";
import moment from "moment";
@@ -55,7 +47,7 @@ moment.updateLocale("en", {
},
});
-const requirements = [ngRoute, ngResource, ngSanitize, uiBootstrap, uiSelect, "angularResizable", vsRepeat];
+const requirements = [ngRoute, ngResource, ngSanitize];
const ngModule = angular.module("app", requirements);
@@ -77,13 +69,6 @@ function requireImages() {
ctx.keys().forEach(ctx);
}
-function registerComponents() {
- // We repeat this code in other register functions, because if we don't use a literal for the path
- // Webpack won't be able to statcily analyze our imports.
- const context = require.context("@/components", true, /^((?![\\/.]test[\\./]).)*\.jsx?$/);
- registerAll(context);
-}
-
function registerExtensions() {
const context = require.context("extensions", true, /^((?![\\/.]test[\\./]).)*\.jsx?$/);
registerAll(context);
@@ -106,13 +91,15 @@ function registerPages() {
ngModule.config($routeProvider => {
each(routes, (route, path) => {
logger("Registering route: %s", path);
- route.authenticated = true;
- route.resolve = extend(
- {
- __organizationStatus: () => organizationStatus.refresh(),
- },
- route.resolve
- );
+ route.authenticated = route.authenticated !== false; // could be set to `false` do disable auth
+ if (route.authenticated) {
+ route.resolve = extend(
+ {
+ __organizationStatus: () => organizationStatus.refresh(),
+ },
+ route.resolve
+ );
+ }
$routeProvider.when(path, route);
});
});
@@ -131,22 +118,12 @@ function registerPages() {
});
}
-function registerFilters() {
- each(filters, (filter, name) => {
- ngModule.filter(name, () => filter);
- });
-}
-
requireImages();
-registerDirectives(ngModule);
registerServices();
-registerFilters();
-markdownFilter(ngModule);
-dateTimeFilter(ngModule);
-registerComponents();
+initAppView(ngModule);
registerPages();
registerExtensions();
-registerVisualizations(ngModule);
+registerVisualizations();
ngModule.run($q => {
DialogWrapper.Promise = $q;
diff --git a/client/app/directives/autofocus.js b/client/app/directives/autofocus.js
deleted file mode 100644
index 63e9609f04..0000000000
--- a/client/app/directives/autofocus.js
+++ /dev/null
@@ -1,15 +0,0 @@
-function autofocus($timeout) {
- return {
- link(scope, element) {
- $timeout(() => {
- element[0].focus();
- });
- },
- };
-}
-
-export default function init(ngModule) {
- ngModule.directive("autofocus", autofocus);
-}
-
-init.init = true;
diff --git a/client/app/directives/compare-to.js b/client/app/directives/compare-to.js
deleted file mode 100644
index 87164d34bd..0000000000
--- a/client/app/directives/compare-to.js
+++ /dev/null
@@ -1,28 +0,0 @@
-function compareTo() {
- return {
- require: "ngModel",
- scope: {
- otherModelValue: "=compareTo",
- },
- link(scope, element, attributes, ngModel) {
- const validate = value => {
- ngModel.$setValidity("compareTo", value === scope.otherModelValue);
- };
-
- scope.$watch("otherModelValue", () => {
- validate(ngModel.$modelValue);
- });
-
- ngModel.$parsers.push(value => {
- validate(value);
- return value;
- });
- },
- };
-}
-
-export default function init(ngModule) {
- ngModule.directive("compareTo", compareTo);
-}
-
-init.init = true;
diff --git a/client/app/directives/index.js b/client/app/directives/index.js
deleted file mode 100644
index 17386738b5..0000000000
--- a/client/app/directives/index.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import autofocus from "./autofocus";
-import compareTo from "./compare-to";
-import title from "./title";
-import resizeEvent from "./resize-event";
-import resizableToggle from "./resizable-toggle";
-
-export default function init(ngModule) {
- autofocus(ngModule);
- compareTo(ngModule);
- title(ngModule);
- resizeEvent(ngModule);
- resizableToggle(ngModule);
-}
-
-init.init = true;
diff --git a/client/app/directives/resizable-toggle.js b/client/app/directives/resizable-toggle.js
deleted file mode 100644
index 148fac5d8c..0000000000
--- a/client/app/directives/resizable-toggle.js
+++ /dev/null
@@ -1,122 +0,0 @@
-import { find } from "lodash";
-
-function sameNumber(a, b) {
- return (isNaN(a) && isNaN(b)) || a === b;
-}
-
-const flexBasis =
- find(["flexBasis", "webkitFlexBasis", "msFlexPreferredSize"], prop => prop in document.documentElement.style) ||
- "flexBasis";
-
-const threshold = 5;
-
-function resizableToggle(KeyboardShortcuts) {
- return {
- link($scope, $element, $attrs) {
- if ($attrs.resizable === "false") return;
-
- let ignoreResizeEvents = false;
-
- let resizeStartInfo = null;
- let allowHClick = true;
- let allowVClick = true;
- let lastWidth = $element.width();
- let lastHeight = $element.height();
-
- const isFlex = $scope.$eval($attrs.rFlex);
-
- const shortcuts = {
- [$attrs.toggleShortcut]: () => {
- // It's a bit jQuery-way to handle this, but I really don't want
- // to add any custom code that will detect resizer direction (keep
- // in mind that this component is a hook to another 3dr-party one).
- // So let's just find any available handle and "click" it, and let
- // `angular-resizable` does it's job
- $element.find(".rg-left, .rg-right, .rg-top, .rg-bottom").click();
- },
- };
-
- KeyboardShortcuts.bind(shortcuts);
- $scope.$on("$destroy", () => {
- KeyboardShortcuts.unbind(shortcuts);
- });
-
- $scope.$on("angular-resizable.resizeStart", ($event, info) => {
- if (!ignoreResizeEvents) {
- resizeStartInfo = Object.assign({}, info);
- }
- });
-
- $scope.$on("angular-resizable.resizeEnd", ($event, info) => {
- if (!ignoreResizeEvents) {
- allowHClick = true;
- if (info.width !== false) {
- allowHClick = sameNumber(info.width, resizeStartInfo.width);
- }
- allowVClick = true;
- if (info.height !== false) {
- allowVClick = sameNumber(info.height, resizeStartInfo.height);
- }
- }
- });
-
- function emulateAngularResizableEvents(width, height) {
- ignoreResizeEvents = true;
- const info = {
- width,
- height,
- id: $element.attr("id"),
- evt: null,
- };
- $scope.$emit("angular-resizable.resizeStart", info);
- $scope.$emit("angular-resizable.resizing", info);
- $scope.$emit("angular-resizable.resizeEnd", info);
- ignoreResizeEvents = false;
- }
-
- $element.on("click", ".rg-left, .rg-right", () => {
- if (allowHClick) {
- const minSize = parseFloat($element.css("min-width")) + threshold;
- const currentSize = $element.width();
- const isCollapsed = currentSize <= minSize;
- const animateProp = isFlex ? flexBasis : "width";
- if (isCollapsed) {
- $element.animate({ [animateProp]: lastWidth + "px" }, 300, () => {
- emulateAngularResizableEvents(lastWidth, false);
- });
- } else {
- lastWidth = currentSize;
- $element.css({ [animateProp]: currentSize + "px" }).animate({ [animateProp]: 0 }, 300, () => {
- emulateAngularResizableEvents(0, false);
- });
- }
- }
- });
-
- $element.on("click", ".rg-top, .rg-bottom", () => {
- if (allowVClick) {
- const minSize = parseFloat($element.css("min-height")) + threshold;
- const currentSize = $element.height();
- const isCollapsed = currentSize <= minSize;
- const animateProp = isFlex ? flexBasis : "height";
- if (isCollapsed) {
- $element.animate({ [animateProp]: lastHeight + "px" }, 300, () => {
- emulateAngularResizableEvents(false, lastHeight);
- });
- } else {
- lastHeight = currentSize;
- $element.css({ [animateProp]: currentSize + "px" }).animate({ [animateProp]: 0 }, 300, () => {
- emulateAngularResizableEvents(false, 0);
- });
- }
- }
- });
- },
- };
-}
-
-export default function init(ngModule) {
- ngModule.directive("resizableToggle", resizableToggle);
-}
-
-init.init = true;
diff --git a/client/app/directives/resize-event.js b/client/app/directives/resize-event.js
deleted file mode 100644
index 4184db32c0..0000000000
--- a/client/app/directives/resize-event.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import resizeObserver from "@/services/resizeObserver";
-
-function resizeEvent() {
- return {
- restrict: "A",
- link($scope, $element, attrs) {
- const unwatch = resizeObserver($element[0], () => {
- $scope.$evalAsync(attrs.resizeEvent);
- });
- $scope.$on("$destroy", unwatch);
- },
- };
-}
-
-export default function init(ngModule) {
- ngModule.directive("resizeEvent", resizeEvent);
-}
-
-init.init = true;
diff --git a/client/app/directives/title.js b/client/app/directives/title.js
deleted file mode 100644
index c2507e0143..0000000000
--- a/client/app/directives/title.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import { logger } from "./utils";
-
-function TitleService($rootScope) {
- const Title = {
- title: "Redash",
- set(newTitle) {
- this.title = newTitle;
- $rootScope.$broadcast("$titleChange");
- },
- get() {
- return this.title;
- },
- };
-
- return Title;
-}
-
-function title($rootScope, Title) {
- return {
- restrict: "E",
- link(scope, element) {
- function updateTitle() {
- const newTitle = Title.get();
- logger("Updating title to: %s", newTitle);
- element.text(newTitle);
- }
-
- $rootScope.$on("$routeChangeSuccess", (event, to) => {
- if (to.title) {
- Title.set(to.title);
- }
- });
- $rootScope.$on("$titleChange", updateTitle);
- },
- };
-}
-
-export default function init(ngModule) {
- ngModule.factory("Title", TitleService).directive("title", title);
-}
-
-init.init = true;
diff --git a/client/app/directives/utils.js b/client/app/directives/utils.js
deleted file mode 100644
index a1798c0c7c..0000000000
--- a/client/app/directives/utils.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import debug from "debug";
-
-export const logger = debug("redash:directives");
-
-export const requestAnimationFrame =
- window.requestAnimationFrame ||
- window.webkitRequestAnimationFrame ||
- window.mozRequestAnimationFrame ||
- window.msRequestAnimationFrame ||
- function requestAnimationFrameFallback(callback) {
- setTimeout(callback, 10);
- };
diff --git a/client/app/filters/datetime.js b/client/app/filters/datetime.js
deleted file mode 100644
index 3ece6e9741..0000000000
--- a/client/app/filters/datetime.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import moment from "moment";
-import { clientConfig } from "@/services/auth";
-
-export function formatDateTime(value) {
- if (!value) {
- return "";
- }
-
- const parsed = moment(value);
- if (!parsed.isValid()) {
- return "-";
- }
-
- return parsed.format(clientConfig.dateTimeFormat);
-}
-
-export function formatDate(value) {
- if (!value) {
- return "";
- }
-
- const parsed = moment(value);
- if (!parsed.isValid()) {
- return "-";
- }
-
- return parsed.format(clientConfig.dateFormat);
-}
-
-export default function init(ngModule) {
- ngModule.filter("toMilliseconds", () => value => value * 1000.0);
- ngModule.filter("dateTime", () => formatDateTime);
-}
-
-init.init = true;
diff --git a/client/app/filters/index.js b/client/app/filters/index.js
deleted file mode 100644
index dc1466e4b3..0000000000
--- a/client/app/filters/index.js
+++ /dev/null
@@ -1,186 +0,0 @@
-import moment from "moment";
-import { capitalize as _capitalize, isEmpty } from "lodash";
-import { formatDate, formatDateTime } from "./datetime";
-
-export const IntervalEnum = {
- NEVER: "Never",
- SECONDS: "second",
- MINUTES: "minute",
- HOURS: "hour",
- DAYS: "day",
- WEEKS: "week",
-};
-
-export function localizeTime(time) {
- const [hrs, mins] = time.split(":");
- return moment
- .utc()
- .hour(hrs)
- .minute(mins)
- .local()
- .format("HH:mm");
-}
-
-export function secondsToInterval(count) {
- if (!count) {
- return { interval: IntervalEnum.NEVER };
- }
-
- let interval = IntervalEnum.SECONDS;
- if (count >= 60) {
- count /= 60;
- interval = IntervalEnum.MINUTES;
- }
- if (count >= 60) {
- count /= 60;
- interval = IntervalEnum.HOURS;
- }
- if (count >= 24 && interval === IntervalEnum.HOURS) {
- count /= 24;
- interval = IntervalEnum.DAYS;
- }
- if (count >= 7 && !(count % 7) && interval === IntervalEnum.DAYS) {
- count /= 7;
- interval = IntervalEnum.WEEKS;
- }
- return { count, interval };
-}
-
-export function intervalToSeconds(count, interval) {
- let intervalInSeconds = 0;
- switch (interval) {
- case IntervalEnum.MINUTES:
- intervalInSeconds = 60;
- break;
- case IntervalEnum.HOURS:
- intervalInSeconds = 3600;
- break;
- case IntervalEnum.DAYS:
- intervalInSeconds = 86400;
- break;
- case IntervalEnum.WEEKS:
- intervalInSeconds = 604800;
- break;
- default:
- return null;
- }
- return intervalInSeconds * count;
-}
-
-export function pluralize(text, count) {
- const should = count !== 1;
- return text + (should ? "s" : "");
-}
-
-export function durationHumanize(duration, options = {}) {
- if (!duration) {
- return "-";
- }
- let ret = "";
- const { interval, count } = secondsToInterval(duration);
- const rounded = Math.round(count);
- if (rounded !== 1 || !options.omitSingleValueNumber) {
- ret = `${rounded} `;
- }
- ret += pluralize(interval, rounded);
- return ret;
-}
-
-export function toHuman(text) {
- return text.replace(/_/g, " ").replace(/(?:^|\s)\S/g, a => a.toUpperCase());
-}
-
-export function colWidth(widgetWidth) {
- if (widgetWidth === 0) {
- return 0;
- } else if (widgetWidth === 1) {
- return 6;
- } else if (widgetWidth === 2) {
- return 12;
- }
- return widgetWidth;
-}
-
-export function capitalize(text) {
- if (text) {
- return _capitalize(text);
- }
-
- return null;
-}
-
-export function remove(items, item) {
- if (items === undefined) {
- return items;
- }
-
- let notEquals;
-
- if (item instanceof Array) {
- notEquals = other => item.indexOf(other) === -1;
- } else {
- notEquals = other => item !== other;
- }
-
- const filtered = [];
-
- for (let i = 0; i < items.length; i += 1) {
- if (notEquals(items[i])) {
- filtered.push(items[i]);
- }
- }
-
- return filtered;
-}
-
-export function notEmpty(collection) {
- return !isEmpty(collection);
-}
-
-export function showError(field) {
- // In case of booleans, we get an undefined here.
- if (field === undefined) {
- return false;
- }
- return field.$touched && field.$invalid;
-}
-
-const units = ["bytes", "KB", "MB", "GB", "TB", "PB"];
-
-export function prettySize(bytes) {
- if (isNaN(parseFloat(bytes)) || !isFinite(bytes)) {
- return "?";
- }
-
- let unit = 0;
-
- while (bytes >= 1024) {
- bytes /= 1024;
- unit += 1;
- }
-
- return bytes.toFixed(3) + " " + units[unit];
-}
-
-export function join(arr) {
- if (arr === undefined || arr === null) {
- return "";
- }
-
- return arr.join(" / ");
-}
-
-export function formatColumnValue(value, columnType = null) {
- if (moment.isMoment(value)) {
- if (columnType === "date") {
- return formatDate(value);
- }
- return formatDateTime(value);
- }
-
- if (typeof value === "boolean") {
- return value.toString();
- }
-
- return value;
-}
diff --git a/client/app/filters/markdown.js b/client/app/filters/markdown.js
deleted file mode 100644
index c6b4f81728..0000000000
--- a/client/app/filters/markdown.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { markdown } from "markdown";
-
-export default function init(ngModule) {
- ngModule.filter(
- "markdown",
- ($sce, clientConfig) =>
- function parseMarkdown(text) {
- if (!text) {
- return "";
- }
-
- let html = markdown.toHTML(String(text));
- if (clientConfig.allowScriptsInUserInput) {
- html = $sce.trustAsHtml(html);
- }
-
- return html;
- }
- );
-}
-
-init.init = true;
diff --git a/client/app/index.js b/client/app/index.js
index c25b0589a9..4179a8af79 100644
--- a/client/app/index.js
+++ b/client/app/index.js
@@ -1,18 +1,9 @@
import ngModule from "@/config";
-ngModule.config(($locationProvider, $compileProvider, uiSelectConfig) => {
+ngModule.config(($locationProvider, $compileProvider) => {
$compileProvider.debugInfoEnabled(false);
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|data|tel|sms|mailto):/);
$locationProvider.html5Mode(true);
- uiSelectConfig.theme = "bootstrap";
-});
-
-// Update ui-select's template to use Font-Awesome instead of glyphicon.
-ngModule.run($templateCache => {
- const templateName = "bootstrap/match.tpl.html";
- let template = $templateCache.get(templateName);
- template = template.replace("glyphicon glyphicon-remove", "fa fa-remove");
- $templateCache.put(templateName, template);
});
export default ngModule;
diff --git a/client/app/lib/hooks/useQueryResult.js b/client/app/lib/hooks/useQueryResult.js
index 50269ee36d..fba258f947 100644
--- a/client/app/lib/hooks/useQueryResult.js
+++ b/client/app/lib/hooks/useQueryResult.js
@@ -1,30 +1,66 @@
-import { useState, useEffect } from "react";
-import { invoke } from "lodash";
+import { includes, get, invoke } from "lodash";
+import { useState, useEffect, useRef } from "react";
+
+function getQueryResultStatus(queryResult) {
+ return invoke(queryResult, "getStatus") || null;
+}
+
+function isFinalStatus(status) {
+ return includes(["done", "failed"], status);
+}
function getQueryResultData(queryResult) {
return {
+ status: getQueryResultStatus(queryResult),
columns: invoke(queryResult, "getColumns") || [],
rows: invoke(queryResult, "getData") || [],
filters: invoke(queryResult, "getFilters") || [],
+ updatedAt: invoke(queryResult, "getUpdatedAt") || null,
+ retrievedAt: get(queryResult, "query_result.retrieved_at", null),
+ log: invoke(queryResult, "getLog") || [],
+ error: invoke(queryResult, "getError") || null,
+ runtime: invoke(queryResult, "getRuntime") || null,
+ metadata: get(queryResult, "query_result.data.metadata", {}),
};
}
export default function useQueryResult(queryResult) {
const [data, setData] = useState(getQueryResultData(queryResult));
+ const queryResultRef = useRef(queryResult);
+ const lastStatusRef = useRef(getQueryResultStatus(queryResult));
+
useEffect(() => {
- let isCancelled = false;
- if (queryResult) {
- queryResult.toPromise().then(() => {
- if (!isCancelled) {
- setData(getQueryResultData(queryResult));
- }
- });
- } else {
+ // This check is needed to avoid unnecessary updates.
+ // `useState`/`useRef` hooks use their argument (initial value) only on the first run.
+ // When `useEffect` will run for the first time, that values will be already properly
+ // initialized, so we just need to start polling.
+ // If `queryResult` object will later change - `useState`/`useRef` will not update values;
+ // in that case this section will not be skipped and will update internal state properly.
+ if (queryResult !== queryResultRef.current) {
+ queryResultRef.current = queryResult;
+ lastStatusRef.current = getQueryResultStatus(queryResult);
setData(getQueryResultData(queryResult));
}
- return () => {
- isCancelled = true;
- };
+
+ if (!isFinalStatus(lastStatusRef.current)) {
+ let timer = setInterval(() => {
+ const currentStatus = getQueryResultStatus(queryResultRef.current);
+ if (lastStatusRef.current !== currentStatus) {
+ lastStatusRef.current = currentStatus;
+ setData(getQueryResultData(queryResultRef.current));
+ }
+
+ if (isFinalStatus(currentStatus)) {
+ clearInterval(timer);
+ timer = null;
+ }
+ }, 200);
+
+ return () => {
+ clearInterval(timer);
+ };
+ }
}, [queryResult]);
+
return data;
}
diff --git a/client/app/lib/utils.js b/client/app/lib/utils.js
index a654a6d013..d215b6d4ee 100644
--- a/client/app/lib/utils.js
+++ b/client/app/lib/utils.js
@@ -1,4 +1,163 @@
import { isObject, cloneDeep, each, extend } from "lodash";
+import moment from "moment";
+import { clientConfig } from "@/services/auth";
+
+export const IntervalEnum = {
+ NEVER: "Never",
+ SECONDS: "second",
+ MINUTES: "minute",
+ HOURS: "hour",
+ DAYS: "day",
+ WEEKS: "week",
+};
+
+export function formatDateTime(value) {
+ if (!value) {
+ return "";
+ }
+
+ const parsed = moment(value);
+ if (!parsed.isValid()) {
+ return "-";
+ }
+
+ return parsed.format(clientConfig.dateTimeFormat);
+}
+
+export function formatDate(value) {
+ if (!value) {
+ return "";
+ }
+
+ const parsed = moment(value);
+ if (!parsed.isValid()) {
+ return "-";
+ }
+
+ return parsed.format(clientConfig.dateFormat);
+}
+
+export function localizeTime(time) {
+ const [hrs, mins] = time.split(":");
+ return moment
+ .utc()
+ .hour(hrs)
+ .minute(mins)
+ .local()
+ .format("HH:mm");
+}
+
+export function secondsToInterval(count) {
+ if (!count) {
+ return { interval: IntervalEnum.NEVER };
+ }
+
+ let interval = IntervalEnum.SECONDS;
+ if (count >= 60) {
+ count /= 60;
+ interval = IntervalEnum.MINUTES;
+ }
+ if (count >= 60) {
+ count /= 60;
+ interval = IntervalEnum.HOURS;
+ }
+ if (count >= 24 && interval === IntervalEnum.HOURS) {
+ count /= 24;
+ interval = IntervalEnum.DAYS;
+ }
+ if (count >= 7 && !(count % 7) && interval === IntervalEnum.DAYS) {
+ count /= 7;
+ interval = IntervalEnum.WEEKS;
+ }
+ return { count, interval };
+}
+
+export function pluralize(text, count) {
+ const should = count !== 1;
+ return text + (should ? "s" : "");
+}
+
+export function durationHumanize(duration, options = {}) {
+ if (!duration) {
+ return "-";
+ }
+ let ret = "";
+ const { interval, count } = secondsToInterval(duration);
+ const rounded = Math.round(count);
+ if (rounded !== 1 || !options.omitSingleValueNumber) {
+ ret = `${rounded} `;
+ }
+ ret += pluralize(interval, rounded);
+ return ret;
+}
+
+export function toHuman(text) {
+ return text.replace(/_/g, " ").replace(/(?:^|\s)\S/g, a => a.toUpperCase());
+}
+
+export function remove(items, item) {
+ if (items === undefined) {
+ return items;
+ }
+
+ let notEquals;
+
+ if (item instanceof Array) {
+ notEquals = other => item.indexOf(other) === -1;
+ } else {
+ notEquals = other => item !== other;
+ }
+
+ const filtered = [];
+
+ for (let i = 0; i < items.length; i += 1) {
+ if (notEquals(items[i])) {
+ filtered.push(items[i]);
+ }
+ }
+
+ return filtered;
+}
+
+const units = ["bytes", "KB", "MB", "GB", "TB", "PB"];
+
+export function prettySize(bytes) {
+ if (isNaN(parseFloat(bytes)) || !isFinite(bytes)) {
+ return "?";
+ }
+
+ let unit = 0;
+
+ while (bytes >= 1024) {
+ bytes /= 1024;
+ unit += 1;
+ }
+
+ return bytes.toFixed(3) + " " + units[unit];
+}
+
+export function join(arr) {
+ if (arr === undefined || arr === null) {
+ return "";
+ }
+
+ return arr.join(" / ");
+}
+
+export function formatColumnValue(value, columnType = null) {
+ if (moment.isMoment(value)) {
+ if (columnType === "date") {
+ return formatDate(value);
+ }
+ return formatDateTime(value);
+ }
+
+ if (typeof value === "boolean") {
+ return value.toString();
+ }
+
+ return value;
+}
export function routesToAngularRoutes(routes, template) {
const result = {};
diff --git a/client/app/pages/admin/OutdatedQueries.jsx b/client/app/pages/admin/OutdatedQueries.jsx
index 846b55630e..b6d05bdc95 100644
--- a/client/app/pages/admin/OutdatedQueries.jsx
+++ b/client/app/pages/admin/OutdatedQueries.jsx
@@ -4,10 +4,10 @@ import { react2angular } from "react2angular";
import Switch from "antd/lib/switch";
import * as Grid from "antd/lib/grid";
-import { Paginator } from "@/components/Paginator";
+import Paginator from "@/components/Paginator";
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
-import { SchedulePhrase } from "@/components/queries/SchedulePhrase";
-import { TimeAgo } from "@/components/TimeAgo";
+import SchedulePhrase from "@/components/queries/SchedulePhrase";
+import TimeAgo from "@/components/TimeAgo";
import Layout from "@/components/admin/Layout";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
diff --git a/client/app/pages/alert/AlertView.jsx b/client/app/pages/alert/AlertView.jsx
index 58cfcacf09..b8e073d11b 100644
--- a/client/app/pages/alert/AlertView.jsx
+++ b/client/app/pages/alert/AlertView.jsx
@@ -2,7 +2,7 @@ import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
-import { TimeAgo } from "@/components/TimeAgo";
+import TimeAgo from "@/components/TimeAgo";
import { Alert as AlertType } from "@/components/proptypes";
import Form from "antd/lib/form";
diff --git a/client/app/pages/alert/components/Query.jsx b/client/app/pages/alert/components/Query.jsx
index 1047a61f8b..ab5d61c7d7 100644
--- a/client/app/pages/alert/components/Query.jsx
+++ b/client/app/pages/alert/components/Query.jsx
@@ -1,8 +1,8 @@
import React from "react";
import PropTypes from "prop-types";
-import { QuerySelector } from "@/components/QuerySelector";
-import { SchedulePhrase } from "@/components/queries/SchedulePhrase";
+import QuerySelector from "@/components/QuerySelector";
+import SchedulePhrase from "@/components/queries/SchedulePhrase";
import { Query as QueryType } from "@/components/proptypes";
import Tooltip from "antd/lib/tooltip";
diff --git a/client/app/pages/alerts/AlertsList.jsx b/client/app/pages/alerts/AlertsList.jsx
index 836a5c5c35..6ca4d1e54a 100644
--- a/client/app/pages/alerts/AlertsList.jsx
+++ b/client/app/pages/alerts/AlertsList.jsx
@@ -2,8 +2,8 @@ import React from "react";
import { react2angular } from "react2angular";
import { toUpper } from "lodash";
-import { PageHeader } from "@/components/PageHeader";
-import { Paginator } from "@/components/Paginator";
+import PageHeader from "@/components/PageHeader";
+import Paginator from "@/components/Paginator";
import EmptyState from "@/components/empty-state/EmptyState";
import { wrap as liveItemsList, ControllerType } from "@/components/items-list/ItemsList";
diff --git a/client/app/pages/dashboards/DashboardList.jsx b/client/app/pages/dashboards/DashboardList.jsx
index 66891eafc7..967acb23c2 100644
--- a/client/app/pages/dashboards/DashboardList.jsx
+++ b/client/app/pages/dashboards/DashboardList.jsx
@@ -1,8 +1,8 @@
import React from "react";
import { react2angular } from "react2angular";
-import { PageHeader } from "@/components/PageHeader";
-import { Paginator } from "@/components/Paginator";
+import PageHeader from "@/components/PageHeader";
+import Paginator from "@/components/Paginator";
import { DashboardTagsControl } from "@/components/tags-control/TagsControl";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
diff --git a/client/app/pages/dashboards/DashboardListEmptyState.jsx b/client/app/pages/dashboards/DashboardListEmptyState.jsx
index a3b00bc08b..bef53e7617 100644
--- a/client/app/pages/dashboards/DashboardListEmptyState.jsx
+++ b/client/app/pages/dashboards/DashboardListEmptyState.jsx
@@ -1,7 +1,7 @@
import React from "react";
import PropTypes from "prop-types";
import BigMessage from "@/components/BigMessage";
-import { NoTaggedObjectsFound } from "@/components/NoTaggedObjectsFound";
+import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
import EmptyState from "@/components/empty-state/EmptyState";
export default function DashboardListEmptyState({ page, searchTerm, selectedTags }) {
diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx
index 9f7ff5d068..9d8071c610 100644
--- a/client/app/pages/dashboards/DashboardPage.jsx
+++ b/client/app/pages/dashboards/DashboardPage.jsx
@@ -11,10 +11,10 @@ import Icon from "antd/lib/icon";
import Modal from "antd/lib/modal";
import Tooltip from "antd/lib/tooltip";
import DashboardGrid from "@/components/dashboards/DashboardGrid";
-import { FavoritesControl } from "@/components/FavoritesControl";
-import { EditInPlace } from "@/components/EditInPlace";
+import FavoritesControl from "@/components/FavoritesControl";
+import EditInPlace from "@/components/EditInPlace";
import { DashboardTagsControl } from "@/components/tags-control/TagsControl";
-import { Parameters } from "@/components/Parameters";
+import Parameters from "@/components/Parameters";
import Filters from "@/components/Filters";
import { Dashboard } from "@/services/dashboard";
import recordEvent from "@/services/recordEvent";
@@ -22,7 +22,7 @@ import { $route } from "@/services/ng";
import getTags from "@/services/getTags";
import { clientConfig } from "@/services/auth";
import { policy } from "@/services/policy";
-import { durationHumanize } from "@/filters";
+import { durationHumanize } from "@/lib/utils";
import PromiseRejectionError from "@/lib/promise-rejection-error";
import useDashboard, { DashboardStatusEnum } from "./useDashboard";
diff --git a/client/app/pages/dashboards/PublicDashboardPage.jsx b/client/app/pages/dashboards/PublicDashboardPage.jsx
index debd19cb7e..b4501a06a1 100644
--- a/client/app/pages/dashboards/PublicDashboardPage.jsx
+++ b/client/app/pages/dashboards/PublicDashboardPage.jsx
@@ -3,8 +3,8 @@ import { isEmpty } from "lodash";
import PropTypes from "prop-types";
import { react2angular } from "react2angular";
import BigMessage from "@/components/BigMessage";
-import { PageHeader } from "@/components/PageHeader";
-import { Parameters } from "@/components/Parameters";
+import PageHeader from "@/components/PageHeader";
+import Parameters from "@/components/Parameters";
import DashboardGrid from "@/components/dashboards/DashboardGrid";
import Filters from "@/components/Filters";
import { Dashboard } from "@/services/dashboard";
@@ -93,23 +93,21 @@ class PublicDashboardPage extends React.Component {
export default function init(ngModule) {
ngModule.component("publicDashboardPage", react2angular(PublicDashboardPage));
- function session($route, Auth) {
- const token = $route.current.params.token;
- Auth.setApiKey(token);
- return Auth.loadConfig();
- }
-
- ngModule.config($routeProvider => {
- $routeProvider.when("/public/dashboards/:token", {
+ return {
+ "/public/dashboards/:token": {
+ authenticated: false,
template: "
",
reloadOnSearch: false,
resolve: {
- session,
+ session: ($route, Auth) => {
+ "ngInject";
+ const token = $route.current.params.token;
+ Auth.setApiKey(token);
+ return Auth.loadConfig();
+ },
},
- });
- });
-
- return [];
+ },
+ };
}
init.init = true;
diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js
index 8ac08b1f99..df59bf98b6 100644
--- a/client/app/pages/dashboards/useDashboard.js
+++ b/client/app/pages/dashboards/useDashboard.js
@@ -25,7 +25,7 @@ import recordEvent from "@/services/recordEvent";
import { policy } from "@/services/policy";
import AddWidgetDialog from "@/components/dashboards/AddWidgetDialog";
import TextboxDialog from "@/components/dashboards/TextboxDialog";
-import PermissionsEditorDialog from "@/components/permissions-editor/PermissionsEditorDialog";
+import PermissionsEditorDialog from "@/components/PermissionsEditorDialog";
import { editableMappingsToParameterMappings, synchronizeWidgetTitles } from "@/components/ParameterMappingInput";
import ShareDashboardDialog from "./ShareDashboardDialog";
diff --git a/client/app/pages/data-sources/DataSourcesList.jsx b/client/app/pages/data-sources/DataSourcesList.jsx
index 69a9d559e1..b7d334c827 100644
--- a/client/app/pages/data-sources/DataSourcesList.jsx
+++ b/client/app/pages/data-sources/DataSourcesList.jsx
@@ -22,6 +22,8 @@ class DataSourcesList extends React.Component {
loading: true,
};
+ newDataSourceDialog = null;
+
componentDidMount() {
Promise.all([DataSource.query().$promise, DataSource.types().$promise]).then(values =>
this.setState(
@@ -44,6 +46,12 @@ class DataSourcesList extends React.Component {
);
}
+ componentWillUnmount() {
+ if (this.newDataSourceDialog) {
+ this.newDataSourceDialog.dismiss();
+ }
+ }
+
createDataSource = (selectedType, values) => {
const target = { options: {}, type: selectedType.type };
helper.updateTargetWithValues(target, values);
@@ -64,17 +72,24 @@ class DataSourcesList extends React.Component {
showCreateSourceDialog = () => {
recordEvent("view", "page", "data_sources/new");
- CreateSourceDialog.showModal({
+ this.newDataSourceDialog = CreateSourceDialog.showModal({
types: this.state.dataSourceTypes,
sourceType: "Data Source",
imageFolder: IMG_ROOT,
helpTriggerPrefix: "DS_",
onCreate: this.createDataSource,
- }).result.then((result = {}) => {
- if (result.success) {
- navigateTo(`data_sources/${result.data.id}`);
- }
});
+
+ this.newDataSourceDialog.result
+ .then((result = {}) => {
+ this.newDataSourceDialog = null;
+ if (result.success) {
+ navigateTo(`data_sources/${result.data.id}`);
+ }
+ })
+ .catch(() => {
+ this.newDataSourceDialog = null;
+ });
};
renderDataSources() {
diff --git a/client/app/pages/groups/GroupDataSources.jsx b/client/app/pages/groups/GroupDataSources.jsx
index ada1c2ae65..9c1acf2cef 100644
--- a/client/app/pages/groups/GroupDataSources.jsx
+++ b/client/app/pages/groups/GroupDataSources.jsx
@@ -6,7 +6,7 @@ import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import Icon from "antd/lib/icon";
-import { Paginator } from "@/components/Paginator";
+import Paginator from "@/components/Paginator";
import { wrap as liveItemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
diff --git a/client/app/pages/groups/GroupMembers.jsx b/client/app/pages/groups/GroupMembers.jsx
index 54647c7d6c..f5cf0bac5b 100644
--- a/client/app/pages/groups/GroupMembers.jsx
+++ b/client/app/pages/groups/GroupMembers.jsx
@@ -3,7 +3,7 @@ import React from "react";
import { react2angular } from "react2angular";
import Button from "antd/lib/button";
-import { Paginator } from "@/components/Paginator";
+import Paginator from "@/components/Paginator";
import { wrap as liveItemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
diff --git a/client/app/pages/groups/GroupsList.jsx b/client/app/pages/groups/GroupsList.jsx
index 73722292fe..7d5ddce79f 100644
--- a/client/app/pages/groups/GroupsList.jsx
+++ b/client/app/pages/groups/GroupsList.jsx
@@ -2,7 +2,7 @@ import React from "react";
import { react2angular } from "react2angular";
import Button from "antd/lib/button";
-import { Paginator } from "@/components/Paginator";
+import Paginator from "@/components/Paginator";
import { wrap as liveItemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
diff --git a/client/app/pages/queries-list/QueriesList.jsx b/client/app/pages/queries-list/QueriesList.jsx
index 9ffb1c93dc..17229ae8a4 100644
--- a/client/app/pages/queries-list/QueriesList.jsx
+++ b/client/app/pages/queries-list/QueriesList.jsx
@@ -1,10 +1,10 @@
import React from "react";
import { react2angular } from "react2angular";
-import { PageHeader } from "@/components/PageHeader";
-import { Paginator } from "@/components/Paginator";
+import PageHeader from "@/components/PageHeader";
+import Paginator from "@/components/Paginator";
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
-import { SchedulePhrase } from "@/components/queries/SchedulePhrase";
+import SchedulePhrase from "@/components/queries/SchedulePhrase";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
diff --git a/client/app/pages/queries-list/QueriesListEmptyState.jsx b/client/app/pages/queries-list/QueriesListEmptyState.jsx
index a8c61d83d4..fecadba53e 100644
--- a/client/app/pages/queries-list/QueriesListEmptyState.jsx
+++ b/client/app/pages/queries-list/QueriesListEmptyState.jsx
@@ -1,7 +1,7 @@
import React from "react";
import PropTypes from "prop-types";
import BigMessage from "@/components/BigMessage";
-import { NoTaggedObjectsFound } from "@/components/NoTaggedObjectsFound";
+import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
import EmptyState from "@/components/empty-state/EmptyState";
export default function QueriesListEmptyState({ page, searchTerm, selectedTags }) {
diff --git a/client/app/pages/queries/QuerySource.jsx b/client/app/pages/queries/QuerySource.jsx
new file mode 100644
index 0000000000..a603cedbb5
--- /dev/null
+++ b/client/app/pages/queries/QuerySource.jsx
@@ -0,0 +1,477 @@
+import { isEmpty, find, map, extend, includes } from "lodash";
+import React, { useState, useRef, useEffect, useCallback } from "react";
+import PropTypes from "prop-types";
+import { react2angular } from "react2angular";
+import { useDebouncedCallback } from "use-debounce";
+import Select from "antd/lib/select";
+import Resizable from "@/components/Resizable";
+import Parameters from "@/components/Parameters";
+import EditInPlace from "@/components/EditInPlace";
+import EditVisualizationButton from "@/components/EditVisualizationButton";
+import QueryControlDropdown from "@/components/EditVisualizationButton/QueryControlDropdown";
+import QueryEditor from "@/components/queries/QueryEditor";
+import TimeAgo from "@/components/TimeAgo";
+import { routesToAngularRoutes } from "@/lib/utils";
+import { durationHumanize, prettySize } from "@/lib/utils";
+import { Query } from "@/services/query";
+import recordEvent from "@/services/recordEvent";
+
+import QueryPageHeader from "./components/QueryPageHeader";
+import QueryMetadata from "./components/QueryMetadata";
+import QueryVisualizationTabs from "./components/QueryVisualizationTabs";
+import QueryExecutionStatus from "./components/QueryExecutionStatus";
+import SchemaBrowser from "./components/SchemaBrowser";
+import QuerySourceAlerts from "./components/QuerySourceAlerts";
+
+import useQuery from "./hooks/useQuery";
+import useVisualizationTabHandler from "./hooks/useVisualizationTabHandler";
+import useAutocompleteFlags from "./hooks/useAutocompleteFlags";
+import useQueryExecute from "./hooks/useQueryExecute";
+import useQueryDataSources from "./hooks/useQueryDataSources";
+import useDataSourceSchema from "./hooks/useDataSourceSchema";
+import useQueryFlags from "./hooks/useQueryFlags";
+import useQueryParameters from "./hooks/useQueryParameters";
+import useAddToDashboardDialog from "./hooks/useAddToDashboardDialog";
+import useEmbedDialog from "./hooks/useEmbedDialog";
+import useAddNewParameterDialog from "./hooks/useAddNewParameterDialog";
+import useEditScheduleDialog from "./hooks/useEditScheduleDialog";
+import useEditVisualizationDialog from "./hooks/useEditVisualizationDialog";
+import useDeleteVisualization from "./hooks/useDeleteVisualization";
+import useFormatQuery from "./hooks/useFormatQuery";
+import useUpdateQuery from "./hooks/useUpdateQuery";
+import useUpdateQueryDescription from "./hooks/useUpdateQueryDescription";
+import useUnsavedChangesAlert from "./hooks/useUnsavedChangesAlert";
+
+import "./QuerySource.less";
+
+function chooseDataSourceId(dataSourceIds, availableDataSources) {
+ dataSourceIds = map(dataSourceIds, v => parseInt(v, 10));
+ availableDataSources = map(availableDataSources, ds => ds.id);
+ return find(dataSourceIds, id => includes(availableDataSources, id)) || null;
+}
+
+function QuerySource(props) {
+ const { query, setQuery, isDirty, saveQuery } = useQuery(props.query);
+ const { dataSourcesLoaded, dataSources, dataSource } = useQueryDataSources(query);
+ const [schema, refreshSchema] = useDataSourceSchema(dataSource);
+ const queryFlags = useQueryFlags(query, dataSource);
+ const [parameters, areParametersDirty, updateParametersDirtyFlag] = useQueryParameters(query);
+ const [selectedVisualization, setSelectedVisualization] = useVisualizationTabHandler(query.visualizations);
+
+ useUnsavedChangesAlert(isDirty);
+
+ const {
+ queryResult,
+ queryResultData,
+ isQueryExecuting,
+ isExecutionCancelling,
+ executeQuery,
+ executeAdhocQuery,
+ cancelExecution,
+ } = useQueryExecute(query);
+
+ const editorRef = useRef(null);
+ const [autocompleteAvailable, autocompleteEnabled, toggleAutocomplete] = useAutocompleteFlags(schema);
+
+ const [handleQueryEditorChange] = useDebouncedCallback(queryText => {
+ setQuery(extend(query.clone(), { query: queryText }));
+ }, 100);
+
+ useEffect(() => {
+ recordEvent("view_source", "query", query.id);
+ }, [query.id]);
+
+ useEffect(() => {
+ document.title = query.name;
+ }, [query.name]);
+
+ const updateQuery = useUpdateQuery(query, setQuery);
+ const updateQueryDescription = useUpdateQueryDescription(query, setQuery);
+ const formatQuery = useFormatQuery(query, dataSource ? dataSource.syntax : null, setQuery);
+
+ const handleDataSourceChange = useCallback(
+ dataSourceId => {
+ if (dataSourceId) {
+ try {
+ localStorage.setItem("lastSelectedDataSourceId", dataSourceId);
+ } catch (e) {
+ // `localStorage.setItem` may throw exception if there are no enough space - in this case it could be ignored
+ }
+ }
+ if (query.data_source_id !== dataSourceId) {
+ recordEvent("update_data_source", "query", query.id, { dataSourceId });
+ const updates = {
+ data_source_id: dataSourceId,
+ latest_query_data_id: null,
+ latest_query_data: null,
+ };
+ setQuery(extend(query.clone(), updates));
+ updateQuery(updates, { successMessage: null }); // show message only on error
+ }
+ },
+ [query, setQuery, updateQuery]
+ );
+
+ useEffect(() => {
+ // choose data source id for new queries
+ if (dataSourcesLoaded && queryFlags.isNew) {
+ const firstDataSourceId = dataSources.length > 0 ? dataSources[0].id : null;
+ handleDataSourceChange(
+ chooseDataSourceId(
+ [query.data_source_id, localStorage.getItem("lastSelectedDataSourceId"), firstDataSourceId],
+ dataSources
+ )
+ );
+ }
+ }, [query.data_source_id, queryFlags.isNew, dataSourcesLoaded, dataSources, handleDataSourceChange]);
+
+ const openAddToDashboardDialog = useAddToDashboardDialog(query);
+ const openEmbedDialog = useEmbedDialog(query);
+ const editSchedule = useEditScheduleDialog(query, setQuery);
+ const openAddNewParameterDialog = useAddNewParameterDialog(query, (newQuery, param) => {
+ if (editorRef.current) {
+ editorRef.current.paste(param.toQueryTextFragment());
+ editorRef.current.focus();
+ }
+ setQuery(newQuery);
+ });
+
+ const addVisualization = useEditVisualizationDialog(query, queryResult, (newQuery, visualization) => {
+ setQuery(newQuery);
+ setSelectedVisualization(visualization.id);
+ });
+ const editVisualization = useEditVisualizationDialog(query, queryResult, newQuery => setQuery(newQuery));
+ const deleteVisualization = useDeleteVisualization(query, setQuery);
+
+ const handleSchemaItemSelect = useCallback(schemaItem => {
+ if (editorRef.current) {
+ editorRef.current.paste(schemaItem);
+ }
+ }, []);
+
+ const [selectedText, setSelectedText] = useState(null);
+
+ const doExecuteQuery = useCallback(
+ (skipParametersDirtyFlag = false) => {
+ if (!queryFlags.canExecute || (!skipParametersDirtyFlag && areParametersDirty) || isQueryExecuting) {
+ return;
+ }
+ if (isDirty || !isEmpty(selectedText)) {
+ executeAdhocQuery(selectedText);
+ } else {
+ executeQuery();
+ }
+ },
+ [
+ queryFlags.canExecute,
+ areParametersDirty,
+ isQueryExecuting,
+ isDirty,
+ selectedText,
+ executeAdhocQuery,
+ executeQuery,
+ ]
+ );
+
+ return (
+
+
0} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Save
+ {isDirty ? "*" : null}
+
+ ),
+ shortcut: "mod+s",
+ onClick: saveQuery,
+ }
+ }
+ executeButtonProps={{
+ disabled: !queryFlags.canExecute || isQueryExecuting || areParametersDirty,
+ shortcut: "mod+enter, alt+enter",
+ onClick: doExecuteQuery,
+ text: (
+ {selectedText === null ? "Execute" : "Execute Selected"}
+ ),
+ }}
+ autocompleteToggleProps={{
+ available: autocompleteAvailable,
+ enabled: autocompleteEnabled,
+ onToggle: toggleAutocomplete,
+ }}
+ dataSourceSelectorProps={
+ dataSource
+ ? {
+ disabled: !queryFlags.canEdit,
+ value: dataSource.id,
+ onChange: handleDataSourceChange,
+ options: map(dataSources, ds => ({ value: ds.id, label: ds.name })),
+ }
+ : false
+ }
+ />
+
+
+
+
+ {!queryFlags.isNew &&
}
+
+
+ {query.hasParameters() && (
+
+
updateParametersDirtyFlag()}
+ onValuesChange={() => {
+ updateParametersDirtyFlag(false);
+ doExecuteQuery(true);
+ }}
+ onParametersEdit={() => {
+ // save if query clean
+ // https://discuss.redash.io/t/query-unsaved-changes-indication/3302/5
+ if (!isDirty) {
+ saveQuery();
+ }
+ }}
+ />
+
+ )}
+ {queryResult && queryResultData.status !== "done" && (
+
+
+
+ )}
+
+ {queryResultData.status === "done" && (
+
+ {queryResultData.log.length > 0 && (
+
+
Log Information:
+ {map(queryResultData.log, (line, index) => (
+
+ {line}
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+
+ {queryResultData.status === "done" && (
+
+
+ {!queryFlags.isNew && queryFlags.canEdit && (
+
+ )}
+
+
+
+
+ {queryResultData.rows.length}
+ {queryResultData.rows.length === 1 ? " row" : " rows"}
+
+
+ {!isQueryExecuting && (
+
+ {durationHumanize(queryResultData.runtime)}
+ runtime
+
+ )}
+ {isQueryExecuting && Running…}
+
+ {queryResultData.metadata.data_scanned && (
+
+ Data Scanned
+ {prettySize(queryResultData.metadata.data_scanned)}
+
+ )}
+
+
+
+
+ Updated
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+QuerySource.propTypes = {
+ query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+};
+
+export default function init(ngModule) {
+ ngModule.component("pageQuerySource", react2angular(QuerySource));
+
+ return {
+ ...routesToAngularRoutes(
+ [
+ {
+ path: "/queries/new",
+ },
+ ],
+ {
+ layout: "fixed",
+ reloadOnSearch: false,
+ template: '
',
+ resolve: {
+ query: () => Query.newQuery(),
+ },
+ }
+ ),
+ ...routesToAngularRoutes(
+ [
+ {
+ path: "/queries/:queryId/source",
+ },
+ ],
+ {
+ layout: "fixed",
+ reloadOnSearch: false,
+ template: '
',
+ resolve: {
+ query: $route => {
+ "ngInject";
+
+ return Query.get({ id: $route.current.params.queryId }).$promise;
+ },
+ },
+ }
+ ),
+ };
+}
+
+init.init = true;
diff --git a/client/app/pages/queries/QuerySource.less b/client/app/pages/queries/QuerySource.less
new file mode 100644
index 0000000000..1d414fa67c
--- /dev/null
+++ b/client/app/pages/queries/QuerySource.less
@@ -0,0 +1,79 @@
+page-query-source {
+ display: flex;
+ flex-grow: 1;
+}
+
+.query-fullscreen {
+ .query-editor-wrapper {
+ padding: 15px;
+ margin-bottom: 10px;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+
+ .query-editor-container {
+ flex: 1 1 auto;
+
+ &[data-executing] {
+ .ace_marker-layer {
+ .ace_selection {
+ background-color: rgb(255, 210, 181);
+ }
+ }
+ }
+ }
+
+ .query-editor-controls {
+ flex: 0 0 auto;
+ margin-top: 10px;
+ }
+ }
+
+ .query-results-wrapper {
+ display: flex;
+ flex-direction: column;
+ margin: 0 0 15px 0;
+
+ .query-parameters-wrapper {
+ margin: 15px 0 5px 0;
+ flex: 0 0 auto;
+ }
+
+ .query-alerts {
+ margin: 15px 0;
+ flex: 0 0 auto;
+ }
+
+ .query-results-log {
+ padding: 10px;
+ flex: 0 0 auto;
+ }
+
+ .ant-tabs {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+
+ .ant-tabs-bar {
+ flex: 0 0 auto;
+ }
+
+ .ant-tabs-content {
+ flex: 1 1 auto;
+ position: relative;
+
+ @media (min-width: 880px) {
+ .ant-tabs-tabpane {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ overflow: auto;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/client/app/pages/queries/QueryView.jsx b/client/app/pages/queries/QueryView.jsx
new file mode 100644
index 0000000000..0d154b61e7
--- /dev/null
+++ b/client/app/pages/queries/QueryView.jsx
@@ -0,0 +1,199 @@
+import React, { useState, useEffect, useCallback } from "react";
+import PropTypes from "prop-types";
+import { react2angular } from "react2angular";
+import Divider from "antd/lib/divider";
+
+import EditInPlace from "@/components/EditInPlace";
+import Parameters from "@/components/Parameters";
+import TimeAgo from "@/components/TimeAgo";
+import QueryControlDropdown from "@/components/EditVisualizationButton/QueryControlDropdown";
+import EditVisualizationButton from "@/components/EditVisualizationButton";
+
+import { DataSource } from "@/services/data-source";
+import { pluralize, durationHumanize } from "@/lib/utils";
+
+import QueryPageHeader from "./components/QueryPageHeader";
+import QueryVisualizationTabs from "./components/QueryVisualizationTabs";
+import QueryExecutionStatus from "./components/QueryExecutionStatus";
+import QueryMetadata from "./components/QueryMetadata";
+import QueryViewExecuteButton from "./components/QueryViewExecuteButton";
+
+import useVisualizationTabHandler from "./hooks/useVisualizationTabHandler";
+import useQueryExecute from "./hooks/useQueryExecute";
+import useUpdateQueryDescription from "./hooks/useUpdateQueryDescription";
+import useQueryFlags from "./hooks/useQueryFlags";
+import useQueryParameters from "./hooks/useQueryParameters";
+import useAddToDashboardDialog from "./hooks/useAddToDashboardDialog";
+import useEmbedDialog from "./hooks/useEmbedDialog";
+import useEditScheduleDialog from "./hooks/useEditScheduleDialog";
+import useEditVisualizationDialog from "./hooks/useEditVisualizationDialog";
+import useDeleteVisualization from "./hooks/useDeleteVisualization";
+
+function QueryView(props) {
+ const [query, setQuery] = useState(props.query);
+ const [dataSource, setDataSource] = useState();
+ const queryFlags = useQueryFlags(query, dataSource);
+ const [parameters, areParametersDirty, updateParametersDirtyFlag] = useQueryParameters(query);
+ const [selectedVisualization, setSelectedVisualization] = useVisualizationTabHandler(query.visualizations);
+
+ const {
+ queryResult,
+ queryResultData,
+ isQueryExecuting,
+ isExecutionCancelling,
+ executeQuery,
+ cancelExecution,
+ } = useQueryExecute(query);
+
+ const updateQueryDescription = useUpdateQueryDescription(query, setQuery);
+ const openAddToDashboardDialog = useAddToDashboardDialog(query);
+ const openEmbedDialog = useEmbedDialog(query);
+ const editSchedule = useEditScheduleDialog(query, setQuery);
+ const addVisualization = useEditVisualizationDialog(query, queryResult, (newQuery, visualization) => {
+ setQuery(newQuery);
+ setSelectedVisualization(visualization.id);
+ });
+ const editVisualization = useEditVisualizationDialog(query, queryResult, newQuery => setQuery(newQuery));
+ const deleteVisualization = useDeleteVisualization(query, setQuery);
+
+ const doExecuteQuery = useCallback(
+ (skipParametersDirtyFlag = false) => {
+ if (!queryFlags.canExecute || (!skipParametersDirtyFlag && areParametersDirty) || isQueryExecuting) {
+ return;
+ }
+ executeQuery();
+ },
+ [areParametersDirty, executeQuery, isQueryExecuting, queryFlags.canExecute]
+ );
+
+ useEffect(() => {
+ document.title = query.name;
+ }, [query.name]);
+
+ useEffect(() => {
+ DataSource.get({ id: query.data_source_id }).$promise.then(setDataSource);
+ }, [query.data_source_id]);
+
+ return (
+
+
+
+
+
+ {query.hasParameters() && (
+
{
+ updateParametersDirtyFlag(false);
+ doExecuteQuery(true);
+ }}
+ onPendingValuesChange={() => updateParametersDirtyFlag()}
+ />
+ )}
+ {queryResult && queryResultData.status !== "done" && (
+
+
+
+ )}
+ {queryResultData.status === "done" && (
+ <>
+
+
+ >
+ )}
+
+ {queryResultData.status === "done" && (
+ <>
+ {queryFlags.canEdit && (
+
+ )}
+
+
+ {queryResultData.rows.length} {pluralize("row", queryResultData.rows.length)}
+
+
+ {durationHumanize(queryResult.getRuntime())}
+ runtime
+
+ >
+ )}
+
+ {queryResultData.status === "done" && (
+
+ Updated
+
+ )}
+
+ Execute
+
+
+
+
+
+ );
+}
+
+QueryView.propTypes = { query: PropTypes.object.isRequired }; // eslint-disable-line react/forbid-prop-types
+
+export default function init(ngModule) {
+ ngModule.component("pageQueryView", react2angular(QueryView));
+
+ return {
+ "/queries/:queryId": {
+ template: '
',
+ reloadOnSearch: false,
+ resolve: {
+ query: (Query, $route) => {
+ "ngInject";
+
+ return Query.get({ id: $route.current.params.queryId }).$promise;
+ },
+ },
+ },
+ };
+}
+
+init.init = true;
diff --git a/client/app/components/queries/VisualizationEmbed.jsx b/client/app/pages/queries/VisualizationEmbed.jsx
similarity index 95%
rename from client/app/components/queries/VisualizationEmbed.jsx
rename to client/app/pages/queries/VisualizationEmbed.jsx
index 695559e1c3..a45cdb2753 100644
--- a/client/app/components/queries/VisualizationEmbed.jsx
+++ b/client/app/pages/queries/VisualizationEmbed.jsx
@@ -10,15 +10,15 @@ import Icon from "antd/lib/icon";
import Menu from "antd/lib/menu";
import Tooltip from "antd/lib/tooltip";
import { $location, $routeParams } from "@/services/ng";
-import { formatDateTime } from "@/filters/datetime";
+import { formatDateTime } from "@/lib/utils";
import HtmlContent from "@/components/HtmlContent";
-import { Parameters } from "@/components/Parameters";
+import Parameters from "@/components/Parameters";
import { Moment } from "@/components/proptypes";
-import { TimeAgo } from "@/components/TimeAgo";
-import { Timer } from "@/components/Timer";
+import TimeAgo from "@/components/TimeAgo";
+import Timer from "@/components/Timer";
import QueryResultsLink from "@/components/EditVisualizationButton/QueryResultsLink";
import VisualizationName from "@/visualizations/VisualizationName";
-import { VisualizationRenderer } from "@/visualizations/VisualizationRenderer";
+import VisualizationRenderer from "@/visualizations/VisualizationRenderer";
import { VisualizationType } from "@/visualizations";
import logoUrl from "@/assets/images/redash_icon_small.png";
@@ -225,15 +225,16 @@ export default function init(ngModule) {
return loadSession($route, Auth).then(() => Query.get({ id: $route.current.params.queryId }).$promise);
}
- ngModule.config($routeProvider => {
- $routeProvider.when("/embed/query/:queryId/visualization/:visualizationId", {
+ return {
+ "/embed/query/:queryId/visualization/:visualizationId": {
+ authenticated: false,
+ template: '
',
+ reloadOnSearch: false,
resolve: {
query: loadQuery,
},
- reloadOnSearch: false,
- template: '
',
- });
- });
+ },
+ };
}
init.init = true;
diff --git a/client/app/pages/queries/components/QueryExecutionStatus.jsx b/client/app/pages/queries/components/QueryExecutionStatus.jsx
new file mode 100644
index 0000000000..684d272bc2
--- /dev/null
+++ b/client/app/pages/queries/components/QueryExecutionStatus.jsx
@@ -0,0 +1,73 @@
+import { includes } from "lodash";
+import React from "react";
+import PropTypes from "prop-types";
+import Alert from "antd/lib/alert";
+import Button from "antd/lib/button";
+import Timer from "@/components/Timer";
+
+export default function QueryExecutionStatus({ status, updatedAt, error, isCancelling, onCancel }) {
+ const alertType = status === "failed" ? "error" : "info";
+ const showTimer = status !== "failed" && updatedAt;
+ const isCancelButtonAvailable = includes(["waiting", "processing"], status);
+ let message = isCancelling ?
Cancelling… : null;
+
+ switch (status) {
+ case "waiting":
+ if (!isCancelling) {
+ message =
Query in queue…;
+ }
+ break;
+ case "processing":
+ if (!isCancelling) {
+ message =
Executing query…;
+ }
+ break;
+ case "loading-result":
+ message =
Loading results…;
+ break;
+ case "failed":
+ message = (
+
+ Error running query: {error}
+
+ );
+ break;
+ // no default
+ }
+
+ return (
+
+
+ {message} {showTimer && }
+
+
+ {isCancelButtonAvailable && (
+
+ )}
+
+
+ }
+ />
+ );
+}
+
+QueryExecutionStatus.propTypes = {
+ status: PropTypes.string,
+ updatedAt: PropTypes.any,
+ error: PropTypes.string,
+ isCancelling: PropTypes.bool,
+ onCancel: PropTypes.func,
+};
+
+QueryExecutionStatus.defaultProps = {
+ status: "waiting",
+ updatedAt: null,
+ error: null,
+ isCancelling: true,
+ onCancel: () => {},
+};
diff --git a/client/app/pages/queries/components/QueryMetadata.jsx b/client/app/pages/queries/components/QueryMetadata.jsx
new file mode 100644
index 0000000000..a86bb78b94
--- /dev/null
+++ b/client/app/pages/queries/components/QueryMetadata.jsx
@@ -0,0 +1,99 @@
+import { isFunction } from "lodash";
+import React from "react";
+import PropTypes from "prop-types";
+import cx from "classnames";
+import { Moment } from "@/components/proptypes";
+import TimeAgo from "@/components/TimeAgo";
+import SchedulePhrase from "@/components/queries/SchedulePhrase";
+import { IMG_ROOT } from "@/services/data-source";
+
+import "./QueryMetadata.less";
+
+export default function QueryMetadata({ query, dataSource, layout, onEditSchedule }) {
+ return (
+