diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less index 920a1fd147..5da73fa7d0 100755 --- a/client/app/assets/less/inc/base.less +++ b/client/app/assets/less/inc/base.less @@ -1,247 +1,276 @@ -*, button, input, i, a { - -webkit-font-smoothing: antialiased; +*, +button, +input, +i, +a { + -webkit-font-smoothing: antialiased; } *, *:active, *:hover { - outline: none !important; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important; + outline: none !important; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important; } html { - overflow-x: ~"hidden\0/"; - -ms-overflow-style: auto; + overflow-x: ~"hidden\0/"; + -ms-overflow-style: auto; } -html, body { - min-height: 100vh; +html, +body { + min-height: 100vh; } body { - padding-top: 0; - background: #F6F8F9; - font-family: @redash-font; - position: relative; + padding-top: 0; + background: #f6f8f9; + font-family: @redash-font; + position: relative; + app-view { + padding-bottom: 15px; + } + + &.headless { app-view { - padding-bottom: 15px; + padding-top: 10px; + padding-bottom: 0; } - &.headless { - app-view { - padding-top: 10px; - padding-bottom: 0; - } - - .app-header-wrapper { - display: none; - } + .app-header-wrapper { + display: none; } + } } app-view { - min-height: 100vh; + min-height: 100vh; } -app-view, #app-content { - display: flex; - flex-direction: column; - flex-grow: 1; +app-view, +#app-content { + display: flex; + flex-direction: column; + flex-grow: 1; } strong { - font-weight: 500; + font-weight: 500; } #content { - position: relative; - padding-top: 30px; - padding-bottom: 30px; + position: relative; + padding-top: 30px; + padding-bottom: 30px; - @media (min-width: (@screen-sm-min + 1)) { - padding-right: 15px; - padding-left: 15px; - } + @media (min-width: (@screen-sm-min + 1)) { + padding-right: 15px; + padding-left: 15px; + } - @media (min-width: (@screen-lg-min + 80px)) { - margin-left: @sidebar-left-width; - } + @media (min-width: (@screen-lg-min + 80px)) { + margin-left: @sidebar-left-width; + } - @media (min-width: @screen-sm-min) and (max-width: (@screen-md-max + 80px)) { - margin-left: @sidebar-left-mid-width; - } + @media (min-width: @screen-sm-min) and (max-width: (@screen-md-max + 80px)) { + margin-left: @sidebar-left-mid-width; + } - @media (max-width: (@screen-sm-min)) { - margin-left: 0; - } + @media (max-width: (@screen-sm-min)) { + margin-left: 0; + } } .container { - &.c-boxed { - max-width: @boxed-width; - } + &.c-boxed { + max-width: @boxed-width; + } } // Fixed width layout for specific pages @media (min-width: 768px) { - .settings-screen, .home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { - .container { - width: 750px; - } + .settings-screen, + .home-page, + page-dashboard-list, + page-queries-list, + page-alerts-list, + alert-page, + queries-search-results-page, + .fixed-container { + .container { + width: 750px; } + } } @media (min-width: 992px) { - .settings-screen, .home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { - .container { - width: 970px; - } + .settings-screen, + .home-page, + page-dashboard-list, + page-queries-list, + page-alerts-list, + alert-page, + queries-search-results-page, + .fixed-container { + .container { + width: 970px; } + } } @media (min-width: 1200px) { - .settings-screen, .home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { - .container { - width: 1170px; - } + .settings-screen, + .home-page, + page-dashboard-list, + page-queries-list, + page-alerts-list, + alert-page, + queries-search-results-page, + .fixed-container { + .container { + width: 1170px; } + } } .scrollbox { - overflow: auto; - position: relative; + overflow: auto; + position: relative; } .clickable { - cursor: pointer; + cursor: pointer; } .resize-vertical { - resize: vertical !important; - transition: height 0s !important; + resize: vertical !important; + transition: height 0s !important; } .resize-horizontal { - resize: horizontal !important; - transition: width 0s !important; + resize: horizontal !important; + transition: width 0s !important; } .resize-both, .resize-vertical.resize-horizontal { - resize: both !important; - transition: height 0s, width 0s !important; + resize: both !important; + transition: height 0s, width 0s !important; } .bg-ace { - background-color: fade(@redash-gray, 12%) !important; + background-color: fade(@redash-gray, 12%) !important; } // resizeable -.rg-top span, .rg-bottom span { - height: 3px; - border-color: #b1c1ce; // TODO: variable +.rg-top span, +.rg-bottom span { + height: 3px; + border-color: #b1c1ce; // TODO: variable } .rg-bottom { - bottom: 15px; + bottom: 15px; - span { - margin: 1.5px 0 0 -10px; - } + span { + margin: 1.5px 0 0 -10px; + } } // Plotly text.slicetext { - text-shadow: 1px 1px 5px #333; + text-shadow: 1px 1px 5px #333; } // markdown .markdown strong { - font-weight: bold; + font-weight: bold; } .markdown img { - max-width: 100%; + max-width: 100%; } -.dropdown-menu > li > a:hover, .dropdown-menu > li > a:focus { - background-color: fade(@redash-gray, 15%); - color: #111; +.dropdown-menu > li > a:hover, +.dropdown-menu > li > a:focus { + background-color: fade(@redash-gray, 15%); + color: #111; } .profile__image--sidebar { - border-radius: 100%; - margin-right: 3px; - margin-top: -2px; + border-radius: 100%; + margin-right: 3px; + margin-top: -2px; } .profile__image--settings { - border-radius: 100%; + border-radius: 100%; } .profile__image_thumb { - border-radius: 100%; - margin-right: 3px; - margin-top: -2px; - width: 20px; - height: 20px; + border-radius: 100%; + margin-right: 3px; + margin-top: -2px; + width: 20px; + height: 20px; } - // Error state .error-state { - display: flex; - flex-direction: column; - justify-content: flex-start; - text-align: center; - margin-top: 25vh; - padding: 35px; - font-size: 14px; - line-height: 21px; - - .error-state__icon { - .zmdi { - font-size: 64px; - color: @redash-gray; - } + display: flex; + flex-direction: column; + justify-content: flex-start; + text-align: center; + margin-top: 25vh; + padding: 35px; + font-size: 14px; + line-height: 21px; + + .error-state__icon { + .zmdi { + font-size: 64px; + color: @redash-gray; } + } - @media (max-width: 767px) { - margin-top: 10vh; - } + @media (max-width: 767px) { + margin-top: 10vh; + } } .warning-icon-danger { - color: @red !important; + color: @red !important; } // page - .page-title { - display: flex; - align-items: center; - - h3 { - margin-right: 5px !important; - } +.page-title { + display: flex; + align-items: center; - .label { - margin-top: 3px; - display: inline-block; - } + h3 { + margin-right: 5px !important; + } - .favorites-control { - font-size: 19px; - margin-right: 5px; - } + .label { + margin-top: 3px; + display: inline-block; } - .page-header-wrapper, .page-header--new { - h3 { - margin: 0.2em 0; - line-height: 1.3; - font-weight: 500; - } + .favorites-control { + font-size: 19px; + margin-right: 5px; } +} - .select-option-divider { - margin: 10px 0 !important; +.page-header-wrapper, +.page-header--new { + h3 { + margin: 0.2em 0; + line-height: 1.3; + font-weight: 500; } +} + +.select-option-divider { + margin: 10px 0 !important; +} diff --git a/client/app/assets/less/inc/schema-browser.less b/client/app/assets/less/inc/schema-browser.less index 0034391086..7f9efa3436 100644 --- a/client/app/assets/less/inc/schema-browser.less +++ b/client/app/assets/less/inc/schema-browser.less @@ -31,6 +31,8 @@ div.table-name { overflow-x: hidden; border: none; margin-top: 10px; + position: relative; + height: 100%; .collapse.in { background: transparent; @@ -72,8 +74,14 @@ div.table-name { .schema-control { display: flex; + flex-wrap: nowrap; padding: 0; + .ant-btn { + height: auto; + } + + // ANGULAR_REMOVE_ME .form-control { margin-right: 5px; } diff --git a/client/app/assets/less/inc/tab.less b/client/app/assets/less/inc/tab.less deleted file mode 100755 index e31179f9b4..0000000000 --- a/client/app/assets/less/inc/tab.less +++ /dev/null @@ -1,146 +0,0 @@ -.tab-nav { - list-style: none; - padding: 0; - white-space: nowrap; - margin: 0 0 10px 0; - overflow: auto; - box-shadow: inset 0 -2px 0 0 #eee; - - & > li { - display: inline-block; - vertical-align: top; - - & > a { - display: inline-block; - color: #7a7a7a; - text-transform: uppercase; - position: relative; - width: 100%; - font-weight: 500; - - &:after { - content: ""; - height: 2px; - position: absolute; - width: 100%; - left: 0; - bottom: 0; - display: none; - } - - @media (min-width: @screen-sm-min) { - padding: 15px; - } - - @media (max-width: @screen-sm-min) { - padding: 15px 8px; - } - } - - &.active { - & > a { - color: #000; - - &:after { - display: block; - } - } - } - } - - &.tab-nav-right { - text-align: right; - } - - &.tn-justified { - & > li { - display: table-cell; - width: 1%; - text-align: center; - } - } - - &.tn-icon { - & > li { - .zmdi { - font-size: 22px; - line-height: 100%; - min-height: 25px; - } - } - } - - &:not([data-tab-color]) { - & > li > a:after { - background: @blue; - } - } - - &[data-tab-color="green"] { - & > li > a:after { - background: @green; - } - } - - &[data-tab-color="red"] { - & > li > a:after { - background: @red; - } - } - - &[data-tab-color="teal"] { - & > li > a:after { - background: @teal; - } - } - - &[data-tab-color="amber"] { - & > li > a:after { - background: @amber; - } - } - - &[data-tab-color="black"] { - & > li > a:after { - background: @black; - } - } - - &[data-tab-color="cyan"] { - & > li > a:after { - background: @cyan; - } - } -} - -.tab-content { - padding: 20px 0; -} - -.rd-tab { - .remove { - cursor: pointer; - color: #A09797; - padding: 0 3px 1px 4px; - font-size: 11px; - &:hover { - color: white; - background-color: #FF8080; - border-radius: 50%; - } - } -} - -.tab-nav { - margin-bottom: 0px; - - > li.rd-tab-btn { - float: right; - padding-right: 10px; - padding-top: 10px; - } - - > li > a { - text-transform: capitalize; - } -} diff --git a/client/app/assets/less/main.less b/client/app/assets/less/main.less index 38bedad859..8428d87a07 100644 --- a/client/app/assets/less/main.less +++ b/client/app/assets/less/main.less @@ -6,8 +6,6 @@ /** Load Vendors Dependencies **/ @import '~font-awesome/less/font-awesome'; -@import '~ui-select/dist/select.css'; -@import '~angular-resizable/src/angular-resizable.css'; @import '~material-design-iconic-font/dist/css/material-design-iconic-font.css'; @import '~pace-progress/themes/blue/pace-theme-minimal.css'; @@ -35,7 +33,6 @@ @import 'inc/alert'; @import 'inc/media'; @import 'inc/modal'; -@import 'inc/tab'; @import 'inc/panel'; @import 'inc/tooltips'; @import 'inc/popover'; diff --git a/client/app/assets/less/redash/query.less b/client/app/assets/less/redash/query.less index 8aa44931ff..f376be0cc9 100644 --- a/client/app/assets/less/redash/query.less +++ b/client/app/assets/less/redash/query.less @@ -17,24 +17,15 @@ body.fixed-layout { } } -.tab-nav .tab-new-vis { - margin: 0 5px; - - > a { - color: @headings-color; - margin-top: 8px; - padding: 7px; - font-weight: 400; - } -} - .bottom-controller { padding: 10px 15px; background: #fff; display: flex; align-items: center; - button, div, span { + button, + div, + span { position: relative; } @@ -44,7 +35,7 @@ body.fixed-layout { } &:before { - content: ''; + content: ""; height: 50px; position: fixed; bottom: 0; @@ -55,7 +46,7 @@ body.fixed-layout { } .p-b-60 { - padding-bottom: 60px !important; + padding-bottom: 60px !important; } .bottom-controller-container { @@ -69,14 +60,16 @@ body.fixed-layout { margin: 0 10px; } -.bottom-controller, .bottom-controller-container { +.bottom-controller, +.bottom-controller-container { .query-metadata__property { margin-right: 5px; } } // Editor -edit-in-place p, span.editable { +edit-in-place p, +span.editable { display: inline-block; } @@ -126,7 +119,6 @@ edit-in-place p.editable:hover { } ._query-metadata__time { - } } @@ -139,7 +131,7 @@ edit-in-place p.editable:hover { .editor__left { height: 100% !important; - width: calc(~'25% - 10px'); + width: calc(~"25% - 10px"); margin-right: 10px; .form-control { @@ -171,7 +163,6 @@ edit-in-place p.editable:hover { } .embed__vis { - } .query__vis { @@ -267,7 +258,22 @@ a.label-tag { display: flex; width: 100vw; - .tile, .tiled { + .resizable-component.react-resizable { + .react-resizable-handle-horizontal { + border-right: 1px solid #efefef; + } + + .react-resizable-handle-vertical { + border-bottom: 1px solid #efefef; + } + } + + .query-metadata-new.query-metadata-horizontal { + border-bottom: 1px solid #efefef; + } + + .tile, + .tiled { box-shadow: none; padding: 15px 0 !important; } @@ -282,9 +288,6 @@ a.label-tag { min-width: 10px; overflow-x: hidden; - .schema-container { - } - .editor__left__data-source, .schema-control, .query-metadata--history, @@ -296,10 +299,23 @@ a.label-tag { border-top: 1px solid #efefef; } - .query-metadata, .editor__left__schema, .editor__left__data-source { + .query-metadata, + .editor__left__schema, + .editor__left__data-source { padding: 15px; } + .editor__left__data-source { + .ant-select { + .ant-select-selection-selected-value { + img, + span { + vertical-align: middle; + } + } + } + } + .editor__left__schema { flex-grow: 1; display: flex; @@ -308,7 +324,7 @@ a.label-tag { padding-top: 0 !important; position: relative; - schema-browser { + .schema-container { position: absolute; left: 15px; top: 0; @@ -317,10 +333,6 @@ a.label-tag { } } } - main { - display: flex; - height: 100%; - } .content { background: #fff; flex-grow: 1; @@ -331,22 +343,13 @@ a.label-tag { padding: 0; overflow-x: hidden; - .editor { - border-bottom: 1px solid #efefef; - } - .pivot-table-visualization-container > table, .visualization-renderer > .visualization-renderer-wrapper { overflow: visible; } - - .tab-nav { - flex-shrink: 0; - } } .row { background: #fff; - z-index: 9; min-height: 50px; &.resizable { @@ -359,6 +362,10 @@ a.label-tag { justify-content: space-around; align-content: space-around; overflow: hidden; + + min-height: 10px; + max-height: 70vh; + flex: 0 0 300px; } .row { @@ -385,7 +392,10 @@ a.label-tag { transition: none !important; } } - .rg-right, .rg-left, .rg-top, .rg-bottom { + .rg-right, + .rg-left, + .rg-top, + .rg-bottom { display: block; width: 10px; height: 10px; @@ -400,32 +410,34 @@ a.label-tag { border: 1px solid #ccc; } } - .rg-right, .rg-left { + .rg-right, + .rg-left { span { border-width: 0 1px; top: 50%; - margin: -10px 0 0 @spacing/4; + margin: -10px 0 0 @spacing / 4; height: 20px; width: 3px; } } - .rg-top, .rg-bottom { + .rg-top, + .rg-bottom { span { border-width: 1px 0; left: 50%; - margin: @spacing/4 0 0 -10px; + margin: @spacing / 4 0 0 -10px; width: 20px; height: 3px; } } - .rg-top { + .rg-top { cursor: row-resize; width: 100%; top: 0; left: 0; - margin-top: -@spacing/2; + margin-top: -@spacing / 2; } - .rg-right { + .rg-right { cursor: col-resize; border-right: 1px solid #efefef; height: 100%; @@ -437,7 +449,7 @@ a.label-tag { background: fade(@redash-gray, 6%); } } - .rg-bottom { + .rg-bottom { cursor: row-resize; background: #fff; width: 100%; @@ -449,7 +461,7 @@ a.label-tag { background: fade(@redash-gray, 6%); } } - .rg-left { + .rg-left { cursor: col-resize; height: 100%; left: 0; @@ -466,7 +478,6 @@ a.label-tag { display: none; } - // Visualization editor .modal-xl .modal-content { border: none; @@ -537,7 +548,6 @@ nav .rg-bottom { .query-tags__mobile { display: none; - margin: -5px 0 0 0; padding: 0 0 0 23px; } @@ -552,7 +562,15 @@ nav .rg-bottom { } .edit-visualization { - margin-right: 5px; + margin-right: 5px; +} + +@media (min-width: 880px) { + .query-fullscreen { + .query-metadata-new.query-metadata-horizontal { + display: none; + } + } } // Smaller screens @@ -582,10 +600,6 @@ nav .rg-bottom { display: none; } - .tab-nav .tab-new-vis { - display: none; - } - .query-fullscreen { flex-direction: column; overflow: hidden; @@ -662,7 +676,8 @@ nav .rg-bottom { } @media (max-width: 768px) { - .editor__left__schema, .editor__left__data-source { + .editor__left__schema, + .editor__left__data-source { display: none; } diff --git a/client/app/components/ColorBox.jsx b/client/app/components/ColorBox.jsx deleted file mode 100644 index bc88a05579..0000000000 --- a/client/app/components/ColorBox.jsx +++ /dev/null @@ -1,12 +0,0 @@ -// ANGULAR_REMOVE_ME -import { react2angular } from "react2angular"; - -import ColorPicker from "@/components/ColorPicker"; - -import "./color-box.less"; - -export default function init(ngModule) { - ngModule.component("colorBox", react2angular(ColorPicker.Swatch)); -} - -init.init = true; diff --git a/client/app/components/EditInPlace.jsx b/client/app/components/EditInPlace.jsx index df7ffbd938..96cff3ae24 100644 --- a/client/app/components/EditInPlace.jsx +++ b/client/app/components/EditInPlace.jsx @@ -1,11 +1,10 @@ +import { trim } from "lodash"; import React from "react"; import PropTypes from "prop-types"; import cx from "classnames"; -import { react2angular } from "react2angular"; -import { trim } from "lodash"; import Input from "antd/lib/input"; -export class EditInPlace extends React.Component { +export default class EditInPlace extends React.Component { static propTypes = { ignoreBlanks: PropTypes.bool, isEditable: PropTypes.bool, @@ -95,9 +94,3 @@ export class EditInPlace extends React.Component { ); } } - -export default function init(ngModule) { - ngModule.component("editInPlace", react2angular(EditInPlace)); -} - -init.init = true; diff --git a/client/app/components/EditParameterSettingsDialog.jsx b/client/app/components/EditParameterSettingsDialog.jsx index d57c1fec67..64c941b946 100644 --- a/client/app/components/EditParameterSettingsDialog.jsx +++ b/client/app/components/EditParameterSettingsDialog.jsx @@ -9,7 +9,7 @@ import Select from "antd/lib/select"; import Input from "antd/lib/input"; import Divider from "antd/lib/divider"; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; -import { QuerySelector } from "@/components/QuerySelector"; +import QuerySelector from "@/components/QuerySelector"; import { Query } from "@/services/query"; const { Option } = Select; diff --git a/client/app/components/EditVisualizationButton/QueryControlDropdown.jsx b/client/app/components/EditVisualizationButton/QueryControlDropdown.jsx index fdbfa2afc3..771d9f2956 100644 --- a/client/app/components/EditVisualizationButton/QueryControlDropdown.jsx +++ b/client/app/components/EditVisualizationButton/QueryControlDropdown.jsx @@ -4,11 +4,10 @@ import Dropdown from "antd/lib/dropdown"; import Menu from "antd/lib/menu"; import Button from "antd/lib/button"; import Icon from "antd/lib/icon"; -import { react2angular } from "react2angular"; import QueryResultsLink from "./QueryResultsLink"; -export function QueryControlDropdown(props) { +export default function QueryControlDropdown(props) { const menu = ( {!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && ( @@ -87,9 +86,3 @@ QueryControlDropdown.defaultProps = { apiKey: "", selectedTab: "", }; - -export default function init(ngModule) { - ngModule.component("queryControlDropdown", react2angular(QueryControlDropdown)); -} - -init.init = true; diff --git a/client/app/components/EditVisualizationButton/index.jsx b/client/app/components/EditVisualizationButton/index.jsx index 0a79605c21..0311d4fd72 100644 --- a/client/app/components/EditVisualizationButton/index.jsx +++ b/client/app/components/EditVisualizationButton/index.jsx @@ -2,9 +2,8 @@ import React from "react"; import PropTypes from "prop-types"; import Button from "antd/lib/button"; import Icon from "antd/lib/icon"; -import { react2angular } from "react2angular"; -export function EditVisualizationButton(props) { +export default function EditVisualizationButton(props) { return ( ', - 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 && ( - + - + )} {autocompleteToggleProps !== false && ( )} - {dataSourceSelectorProps === false && } + {dataSourceSelectorProps === false && } {dataSourceSelectorProps !== false && ( + } } diff --git a/client/app/components/queries/QueryEditor/index.jsx b/client/app/components/queries/QueryEditor/index.jsx index 6be0a90392..fd58cc9c0b 100644 --- a/client/app/components/queries/QueryEditor/index.jsx +++ b/client/app/components/queries/QueryEditor/index.jsx @@ -1,181 +1,183 @@ -import { map, reduce } from "lodash"; -import React, { useRef, useState, useMemo, useEffect, useCallback } from "react"; +import React, { useEffect, useMemo, useState, useCallback, useImperativeHandle } from "react"; import PropTypes from "prop-types"; -import { react2angular } from "react2angular"; -import { DataSource, Schema } from "@/components/proptypes"; -import { Query } from "@/services/query"; -import { KeyboardShortcuts } from "@/services/keyboard-shortcuts"; -import { $rootScope } from "@/services/ng"; -import notification from "@/services/notification"; -import localOptions from "@/lib/localOptions"; - -import QueryEditorComponent from "./QueryEditorComponent"; +import cx from "classnames"; +import { AceEditor, snippetsModule, updateSchemaCompleter } from "./ace"; +import resizeObserver from "@/services/resizeObserver"; +import { QuerySnippet } from "@/services/query-snippet"; + import QueryEditorControls from "./QueryEditorControls"; import "./index.less"; -function QueryEditor({ - queryText, - schema, - addNewParameter, - dataSources, - dataSource, - canEdit, - isDirty, - isQueryOwner, - updateDataSource, - canExecuteQuery, - executeQuery, - queryExecuting, - saveQuery, - updateQuery, - updateSelectedQuery, - listenForEditorCommand, -}) { - const editorRef = useRef(null); - const autocompleteAvailable = useMemo(() => { - const tokensCount = reduce(schema, (totalLength, table) => totalLength + table.columns.length, 0); - return tokensCount <= 5000; - }, [schema]); - const [autocompleteEnabled, setAutocompleteEnabled] = useState(localOptions.get("liveAutocomplete", true)); - const [selectedText, setSelectedText] = useState(null); - - useEffect( - () => - // `listenForEditorCommand` returns function that removes event listener - listenForEditorCommand((e, command, ...args) => { - const editor = editorRef.current; - if (editor) { - switch (command) { - case "focus": { - editor.focus(); - break; - } - case "paste": { - const [text] = args; - editor.paste(text); - $rootScope.$applyAsync(); - break; - } - default: - break; - } - } - }), - [listenForEditorCommand] +const editorProps = { $blockScrolling: Infinity }; + +const QueryEditor = React.forwardRef(function( + { className, syntax, value, autocompleteEnabled, schema, onChange, onSelectionChange, ...props }, + ref +) { + const [container, setContainer] = useState(null); + const [editorRef, setEditorRef] = useState(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) { + const editorId = editorRef.editor.id; + updateSchemaCompleter(editorId, schema); + return () => { + updateSchemaCompleter(editorId, null); + }; + } + }, [schema, editorRef]); + + useEffect(() => { + function resize() { + if (editorRef) { + editorRef.editor.resize(); + } + } + + if (container) { + resize(); + const unwatch = resizeObserver(container, resize); + return unwatch; + } + }, [container, editorRef]); + const handleSelectionChange = useCallback( - text => { - setSelectedText(text); - updateSelectedQuery(text); + selection => { + const rawSelectedQueryText = editorRef.editor.session.doc.getTextRange(selection.getRange()); + const selectedQueryText = rawSelectedQueryText.length > 1 ? rawSelectedQueryText : null; + onSelectionChange(selectedQueryText); }, - [updateSelectedQuery] + [editorRef, onSelectionChange] ); - const formatQuery = useCallback(() => { - Query.format(dataSource.syntax || "sql", queryText) - .then(updateQuery) - .catch(error => notification.error(error)); - }, [dataSource.syntax, queryText, updateQuery]); + 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); + } + }); - const toggleAutocomplete = useCallback(state => { - setAutocompleteEnabled(state); - localOptions.set("liveAutocomplete", state); + 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(); }, []); - const modKey = KeyboardShortcuts.modKey; + useImperativeHandle( + ref, + () => ({ + paste: text => { + if (editorRef) { + const { editor } = editorRef; + editor.session.doc.replace(editor.selection.getRange(), text); + const range = editor.selection.getRange(); + onChange(editor.session.getValue()); + editor.selection.setRange(range); + } + }, + focus: () => { + if (editorRef) { + editorRef.editor.focus(); + } + }, + }), + [editorRef, onChange] + ); return ( -
- + - - - Add New Parameter ({modKey} + P) - - ), - onClick: addNewParameter, - }} - formatButtonProps={{ - title: ( - - Format Query ({modKey} + Shift + F) - - ), - onClick: formatQuery, - }} - saveButtonProps={ - canEdit && { - title: `${modKey} + S`, - text: ( - - Save - {isDirty ? "*" : null} - - ), - onClick: saveQuery, - } - } - executeButtonProps={{ - title: `${modKey} + Enter`, - disabled: !canExecuteQuery || queryExecuting, - onClick: executeQuery, - text: {selectedText === null ? "Execute" : "Execute Selected"}, - }} - autocompleteToggleProps={{ - available: autocompleteAvailable, - enabled: autocompleteEnabled, - onToggle: toggleAutocomplete, - }} - dataSourceSelectorProps={{ - disabled: !isQueryOwner, - value: dataSource.id, - onChange: updateDataSource, - options: map(dataSources, ds => ({ value: ds.id, label: ds.name })), - }} - /> -
+
); -} +}); 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: ` -`, - 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}}) - - -
-
-
{{column}} - -
-
-
-
-
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 ( +
    +
    + Avatar +
    + + {query.user.name} + + + created{" "} + + + + +
    +
    +
    + Avatar +
    + + {query.last_modified_by.name} + + + updated{" "} + + + + +
    +
    + {dataSource && ( +
    + {dataSource.type} +
    +
    {dataSource.name}
    +
    +
    + )} +
    +
    + + + Refresh Schedule + + + + +
    +
    +
    + ); +} + +QueryMetadata.propTypes = { + layout: PropTypes.oneOf(["table", "horizontal"]), + query: PropTypes.shape({ + created_at: PropTypes.oneOfType([PropTypes.string, Moment]).isRequired, + updated_at: PropTypes.oneOfType([PropTypes.string, Moment]).isRequired, + user: PropTypes.shape({ + name: PropTypes.string.isRequired, + profile_image_url: PropTypes.string.isRequired, + is_disabled: PropTypes.bool, + }).isRequired, + last_modified_by: PropTypes.shape({ + name: PropTypes.string.isRequired, + profile_image_url: PropTypes.string.isRequired, + is_disabled: PropTypes.bool, + }).isRequired, + schedule: PropTypes.object, + }).isRequired, + dataSource: PropTypes.shape({ + type: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }), + onEditSchedule: PropTypes.func, +}; + +QueryMetadata.defaultProps = { + layout: "table", + dataSource: null, + onEditSchedule: null, +}; diff --git a/client/app/pages/queries/components/QueryMetadata.less b/client/app/pages/queries/components/QueryMetadata.less new file mode 100644 index 0000000000..72b2c5b29a --- /dev/null +++ b/client/app/pages/queries/components/QueryMetadata.less @@ -0,0 +1,103 @@ +// ANGULAR_REMOVE_ME Change this class to `.query-metadata` when all related Angular code removed +.query-metadata-new { + .query-metadata-item { + display: flex; + flex-wrap: nowrap; + align-items: center; + margin: 0; + + img { + margin: 0 5px 0 0; + } + + .query-metadata-property { + flex: 1 1 auto; + + .query-metadata-label { + margin: 0 5px 0 0; + &:only-child { + margin-right: 0; + } + } + + .query-metadata-value { + margin: 0; + } + } + } + + &.query-metadata-table { + padding: 15px; + border-top: 1px solid #efefef; + + .query-metadata-item { + margin-bottom: 8px; + + &:last-child { + margin-top: 20px; + margin-bottom: 0; + } + + .query-metadata-property { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + } + } + } + + &.query-metadata-horizontal { + padding: 5px 0; + margin: 0 -5px; + display: flex; + flex-wrap: wrap; + align-items: stretch; + justify-content: space-between; + + @media (max-width: 500px) { + & { + flex-direction: column; + justify-content: stretch; + } + } + + @media (min-width: 1000px) { + align-items: flex-start; + justify-content: flex-start; + + .query-metadata-item:last-child { + flex: 1 1 auto; + text-align: right; + } + } + + .query-metadata-item { + padding: 5px; + + &:last-child { + .query-metadata-property { + .query-metadata-label { + white-space: nowrap; + &:after { + content: ":"; + } + } + } + } + + .query-metadata-property { + .query-metadata-label { + .zmdi { + display: none; + } + } + + .query-metadata-value { + strong { + font-weight: normal; + } + } + } + } + } +} diff --git a/client/app/pages/queries/components/QueryPageHeader.jsx b/client/app/pages/queries/components/QueryPageHeader.jsx new file mode 100644 index 0000000000..32fad4569b --- /dev/null +++ b/client/app/pages/queries/components/QueryPageHeader.jsx @@ -0,0 +1,204 @@ +import { extend, map, filter, reduce } from "lodash"; +import React, { useMemo } from "react"; +import PropTypes from "prop-types"; +import cx from "classnames"; +import Button from "antd/lib/button"; +import Dropdown from "antd/lib/dropdown"; +import Menu from "antd/lib/menu"; +import Icon from "antd/lib/icon"; +import EditInPlace from "@/components/EditInPlace"; +import FavoritesControl from "@/components/FavoritesControl"; +import { QueryTagsControl } from "@/components/tags-control/TagsControl"; +import getTags from "@/services/getTags"; +import { clientConfig } from "@/services/auth"; +import useQueryFlags from "../hooks/useQueryFlags"; +import useArchiveQuery from "../hooks/useArchiveQuery"; +import usePublishQuery from "../hooks/usePublishQuery"; +import useUnpublishQuery from "../hooks/useUnpublishQuery"; +import useUpdateQueryTags from "../hooks/useUpdateQueryTags"; +import useRenameQuery from "../hooks/useRenameQuery"; +import useDuplicateQuery from "../hooks/useDuplicateQuery"; +import useApiKeyDialog from "../hooks/useApiKeyDialog"; +import usePermissionsEditorDialog from "../hooks/usePermissionsEditorDialog"; + +function getQueryTags() { + return getTags("api/queries/tags").then(tags => map(tags, t => t.name)); +} + +function createMenu(menu) { + const handlers = {}; + + const groups = map(menu, group => + filter( + map(group, (props, key) => { + props = extend({ isAvailable: true, isEnabled: true, onClick: () => {} }, props); + if (props.isAvailable) { + handlers[key] = props.onClick; + return ( + + {props.title} + + ); + } + return null; + }) + ) + ); + + return ( + handlers[key]()}> + {reduce( + filter(groups, group => group.length > 0), + (result, items, key) => { + const divider = result.length > 0 ? : null; + return [...result, divider, ...items]; + }, + [] + )} + + ); +} + +export default function QueryPageHeader({ query, dataSource, sourceMode, selectedVisualization, onChange }) { + const queryFlags = useQueryFlags(query, dataSource); + const updateName = useRenameQuery(query, onChange); + const updateTags = useUpdateQueryTags(query, onChange); + const archiveQuery = useArchiveQuery(query, onChange); + const publishQuery = usePublishQuery(query, onChange); + const unpublishQuery = useUnpublishQuery(query, onChange); + const duplicateQuery = useDuplicateQuery(query); + const openApiKeyDialog = useApiKeyDialog(query, onChange); + const openPermissionsEditorDialog = usePermissionsEditorDialog(query); + + const moreActionsMenu = useMemo( + () => + createMenu([ + { + fork: { + isEnabled: !queryFlags.isNew && queryFlags.canFork, + title: ( + + Fork + + + ), + onClick: duplicateQuery, + }, + }, + { + archive: { + isAvailable: !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isArchived, + title: "Archive", + onClick: archiveQuery, + }, + managePermissions: { + isAvailable: + !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isArchived && clientConfig.showPermissionsControl, + title: "Manage Permissions", + onClick: openPermissionsEditorDialog, + }, + unpublish: { + isAvailable: !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isDraft, + title: "Unpublish", + onClick: unpublishQuery, + }, + }, + { + showAPIKey: { + isAvailable: !queryFlags.isNew, + title: "Show API Key", + onClick: openApiKeyDialog, + }, + }, + ]), + [queryFlags, archiveQuery, unpublishQuery, openApiKeyDialog, openPermissionsEditorDialog, duplicateQuery] + ); + + return ( +
    +
    +
    + {!queryFlags.isNew && ( + + + + )} +

    + + + + +

    + + {queryFlags.isDraft && !queryFlags.isArchived && !queryFlags.isNew && queryFlags.canEdit && ( + + )} + + {!queryFlags.isNew && queryFlags.canViewSource && ( + + {!sourceMode && ( +