Migrate router and <app-view> to React (#4525)

* Migrate router and <app-view> to React: skeleton

* Update layout on route change

* Start moving page routes from angular to react

* Move page routes to react except of public dashboard and visualization embed)

* Move public dashboard and visualization embed routes to React

* Replace $route/$routeParams usages

* Some cleanup

* Replace AngularJS $location service with implementation based on history library

* Minor fix to how ApplicationView handles route change

* Explicitly use global layout for each page instead of handling related stuff in ApplicationArea component

* Error handling

* Remove AngularJS and related dependencies

* Move Parameter factory method to a separate file

* Fix CSS (replace custom components with classes)

* Fix: keep other url parts when updating location partially; refine code

* Fix tests

* Make router work in multi-org mode (respect <base> tag)

* Optimzation: don't resolve route if path didn't change

* Fix search input in header; error handling improvement (handle more errors in pages; global error handler for unhandled errors; dialog dismiss 'unhandled rejection' errors)

* Fix page keys; fix navigateTo calls (third parameter not available)

* Use relative links

* Router: ignore location REPLACE events, resolve only on PUSH/POP

* Fix tests

* Remove unused jQuery reference

* Show error from backend when creating Destination

* Remove route.resolve where not necessary (used constant values)

* New Query page: keep state on saving, reload when creating another new query

* Use currentRoute.key instead of hard-coded keys for page components

* Tidy up Router

* Tidy up location service

* Fix tests

* Don't add parameters changes to browser's history

* Fix test (improved fix)

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
This commit is contained in:
Levko Kravets
2020-01-20 20:56:37 +02:00
committed by GitHub
parent a891160b4d
commit a682265e13
114 changed files with 2237 additions and 1975 deletions

View File

@@ -10,7 +10,6 @@
"@babel/preset-react"
],
"plugins": [
"angularjs-annotate",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-object-assign",
["babel-plugin-transform-builtin-extend", {

View File

@@ -29,12 +29,12 @@ body {
font-family: @redash-font;
position: relative;
app-view {
#application-root {
padding-bottom: 15px;
}
&.headless {
app-view {
#application-root {
padding-top: 10px;
padding-bottom: 0;
}
@@ -45,11 +45,11 @@ body {
}
}
app-view {
#application-root {
min-height: 100vh;
}
app-view,
#application-root,
#app-content {
display: flex;
flex-direction: column;
@@ -93,11 +93,10 @@ strong {
@media (min-width: 768px) {
.settings-screen,
.home-page,
page-dashboard-list,
page-queries-list,
page-alerts-list,
alert-page,
queries-search-results-page,
.page-dashboard-list,
.page-queries-list,
.page-alerts-list,
.alert-page,
.fixed-container {
.container {
width: 750px;
@@ -108,11 +107,10 @@ strong {
@media (min-width: 992px) {
.settings-screen,
.home-page,
page-dashboard-list,
page-queries-list,
page-alerts-list,
alert-page,
queries-search-results-page,
.page-dashboard-list,
.page-queries-list,
.page-alerts-list,
.alert-page,
.fixed-container {
.container {
width: 970px;
@@ -123,11 +121,10 @@ strong {
@media (min-width: 1200px) {
.settings-screen,
.home-page,
page-dashboard-list,
page-queries-list,
page-alerts-list,
alert-page,
queries-search-results-page,
.page-dashboard-list,
.page-queries-list,
.page-alerts-list,
.alert-page,
.fixed-container {
.container {
width: 1170px;

View File

@@ -39,8 +39,8 @@
}
}
// hide indicator when app-view has content
app-view:not(:empty) ~ .loading-indicator {
// hide indicator when application has content
#application-root:not(:empty) ~ .loading-indicator {
opacity: 0;
transform: scale(0.9);
pointer-events: none;
@@ -48,4 +48,4 @@ app-view:not(:empty) ~ .loading-indicator {
* {
animation: none !important;
}
}
}

View File

@@ -2,7 +2,7 @@ body.fixed-layout {
padding: 0;
overflow: hidden;
app-view {
#application-root {
display: flex;
flex-direction: column;
padding-bottom: 0;
@@ -692,9 +692,5 @@ nav .rg-bottom {
h3 {
font-size: 18px;
}
favorites-control {
margin-top: -3px;
}
}
}

View File

@@ -1,6 +1,6 @@
/* eslint-disable no-template-curly-in-string */
import React, { useRef } from "react";
import React, { useCallback, useRef } from "react";
import Dropdown from "antd/lib/dropdown";
import Button from "antd/lib/button";
@@ -9,25 +9,28 @@ import Menu from "antd/lib/menu";
import Input from "antd/lib/input";
import Tooltip from "antd/lib/tooltip";
import FavoritesDropdown from "./components/FavoritesDropdown";
import HelpTrigger from "@/components/HelpTrigger";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import { currentUser, Auth, clientConfig } from "@/services/auth";
import { $location, $route } from "@/services/ng";
import { Dashboard } from "@/services/dashboard";
import { Query } from "@/services/query";
import frontendVersion from "@/version.json";
import logoUrl from "@/assets/images/redash_icon_small.png";
import "./AppHeader.less";
import FavoritesDropdown from "./FavoritesDropdown";
import "./index.less";
function onSearch(q) {
$location.path("/queries").search({ q });
$route.reload();
navigateTo(`queries?q=${encodeURIComponent(q)}`);
}
function DesktopNavbar() {
const showCreateDashboardDialog = useCallback(() => {
CreateDashboardDialog.showModal().result.catch(() => {}); // ignore dismiss
}, []);
return (
<div className="app-header" data-platform="desktop">
<div>
@@ -62,7 +65,7 @@ function DesktopNavbar() {
)}
{currentUser.hasPermission("create_dashboard") && (
<Menu.Item key="new-dashboard">
<a onMouseUp={() => CreateDashboardDialog.showModal()}>New Dashboard</a>
<a onMouseUp={showCreateDashboardDialog}>New Dashboard</a>
</Menu.Item>
)}
{currentUser.hasPermission("list_alerts") && (
@@ -249,7 +252,7 @@ function MobileNavbar() {
);
}
export default function AppHeader() {
export default function ApplicationHeader() {
return (
<nav className="app-header-wrapper">
<DesktopNavbar />

View File

@@ -8,13 +8,13 @@ nav .app-header {
justify-content: space-between;
margin-bottom: 10px;
background: white;
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, .15);
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
.darker {
color: #333 !important;
&:hover {
color: #2196F3 !important;
color: #2196f3 !important;
}
}
@@ -39,7 +39,7 @@ nav .app-header {
height: 50px;
border-bottom: 0;
}
.ant-btn {
font-weight: 500;
@@ -70,7 +70,7 @@ nav .app-header {
top: 2px;
svg {
transition: transform .2s cubic-bezier(.75,0,.25,1);
transition: transform 0.2s cubic-bezier(0.75, 0, 0.25, 1);
}
}
@@ -140,7 +140,7 @@ nav .app-header {
.menu-item-button {
padding: 0 10px;
}
.ant-menu-root {
margin: 0 5px;
}
@@ -198,10 +198,10 @@ nav .app-header {
.ant-dropdown-menu-item .help-trigger {
display: inline;
color: #2196F3;
color: #2196f3;
vertical-align: bottom;
}
.ant-dropdown-menu.favorites-dropdown {
margin-left: -10px;
}
}

View File

@@ -0,0 +1,61 @@
import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import ErrorBoundary from "@/components/ErrorBoundary";
import { Auth } from "@/services/auth";
import organizationStatus from "@/services/organizationStatus";
import ApplicationHeader from "./ApplicationHeader";
import ErrorMessage from "./ErrorMessage";
export default function AuthenticatedPageWrapper({ bodyClass, children }) {
const [isAuthenticated, setIsAuthenticated] = useState(!!Auth.isAuthenticated());
useEffect(() => {
let isCancelled = false;
Promise.all([Auth.requireSession(), organizationStatus.refresh()])
.then(() => {
if (!isCancelled) {
setIsAuthenticated(!!Auth.isAuthenticated());
}
})
.catch(() => {
if (!isCancelled) {
setIsAuthenticated(false);
}
});
return () => {
isCancelled = true;
};
}, []);
useEffect(() => {
if (bodyClass) {
document.body.classList.toggle(bodyClass, true);
return () => {
document.body.classList.toggle(bodyClass, false);
};
}
}, [bodyClass]);
if (!isAuthenticated) {
return null;
}
return (
<>
<ApplicationHeader />
<ErrorBoundary renderError={error => <ErrorMessage error={error} showOriginalMessage={false} />}>
{children}
</ErrorBoundary>
</>
);
}
AuthenticatedPageWrapper.propTypes = {
bodyClass: PropTypes.string,
children: PropTypes.node,
};
AuthenticatedPageWrapper.defaultProps = {
bodyClass: null,
children: null,
};

View File

@@ -0,0 +1,40 @@
import React from "react";
import PropTypes from "prop-types";
export default function ErrorMessage({ error, showOriginalMessage }) {
if (!error) {
return null;
}
console.error(error);
const message = showOriginalMessage
? error.message
: "It seems like we encountered an error. Try refreshing this page or contact your administrator.";
return (
<div className="fixed-container" data-test="ErrorMessage">
<div className="container">
<div className="col-md-8 col-md-push-2">
<div className="error-state bg-white tiled">
<div className="error-state__icon">
<i className="zmdi zmdi-alert-circle-o" />
</div>
<div className="error-state__details">
<h4>{message}</h4>
</div>
</div>
</div>
</div>
</div>
);
}
ErrorMessage.propTypes = {
error: PropTypes.object.isRequired,
showOriginalMessage: PropTypes.bool,
};
ErrorMessage.defaultProps = {
showOriginalMessage: true,
};

View File

@@ -0,0 +1,154 @@
import { isFunction, map, fromPairs, extend, startsWith, trimStart, trimEnd } from "lodash";
import React, { useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import UniversalRouter from "universal-router";
import ErrorBoundary from "@/components/ErrorBoundary";
import location from "@/services/location";
import url from "@/services/url";
import PromiseRejectionError from "@/lib/promise-rejection-error";
import ErrorMessage from "./ErrorMessage";
function generateRouteKey() {
return Math.random()
.toString(32)
.substr(2);
}
export function stripBase(href) {
// Resolve provided link and '' (root) relative to document's base.
// If provided href is not related to current document (does not
// start with resolved root) - return false. Otherwise
// strip root and return relative url.
const baseHref = trimEnd(url.normalize(""), "/") + "/";
href = url.normalize(href);
if (startsWith(href, baseHref)) {
return "/" + trimStart(href.substr(baseHref.length), "/");
}
return false;
}
function resolveRouteDependencies(route) {
return Promise.all(
map(route.resolve, (value, key) => {
value = isFunction(value) ? value(route.routeParams, route, location) : value;
return Promise.resolve(value).then(result => [key, result]);
})
).then(results => {
route.routeParams = extend(route.routeParams, fromPairs(results));
return route;
});
}
export default function Router({ routes, onRouteChange }) {
const [currentRoute, setCurrentRoute] = useState(null);
const currentPathRef = useRef(null);
const errorHandlerRef = useRef();
useEffect(() => {
let isAbandoned = false;
const router = new UniversalRouter(routes, {
resolveRoute({ route }, routeParams) {
if (isFunction(route.render)) {
return { ...route, routeParams };
}
},
});
function resolve(action) {
if (!isAbandoned) {
if (errorHandlerRef.current) {
errorHandlerRef.current.reset();
}
const pathname = stripBase(location.path);
// This is a optimization for route resolver: if current route was already resolved
// from this path - do nothing. It also prevents router from using outdated route in a case
// when user navigated to another path while current one was still resolving.
// Note: this lock uses only `path` fragment of URL to distinguish routes because currently
// all pages depend only on this fragment and handle search/hash on their own. If router
// should reload page on search/hash change - this fragment (and few checks below) should be updated
if (pathname === currentPathRef.current) {
return;
}
currentPathRef.current = pathname;
// Don't reload controller if URL was replaced
if (action === "REPLACE") {
return;
}
router
.resolve({ pathname })
.then(route => {
return isAbandoned || currentPathRef.current !== pathname ? null : resolveRouteDependencies(route);
})
.then(route => {
if (route) {
setCurrentRoute({ ...route, key: generateRouteKey() });
}
})
.catch(error => {
if (!isAbandoned && currentPathRef.current === pathname) {
setCurrentRoute({
render: params => <ErrorMessage {...params} />,
routeParams: { error: new PromiseRejectionError(error) },
});
}
});
}
}
resolve("PUSH");
const unlisten = location.listen((unused, action) => resolve(action));
return () => {
isAbandoned = true;
unlisten();
};
}, [routes]);
useEffect(() => {
onRouteChange(currentRoute);
}, [currentRoute, onRouteChange]);
if (!currentRoute) {
return null;
}
return (
<ErrorBoundary
ref={errorHandlerRef}
renderError={error => <ErrorMessage error={error} showOriginalMessage={false} />}>
{currentRoute.render(currentRoute)}
</ErrorBoundary>
);
}
Router.propTypes = {
routes: PropTypes.arrayOf(
PropTypes.shape({
path: PropTypes.string.isRequired,
render: PropTypes.func, // (routeParams: PropTypes.object; currentRoute; location) => PropTypes.node
// Additional props to be injected into route component.
// Object keys are props names. Object values will become prop values:
// - if value is a function - it will be called without arguments, and result will be used; otherwise value will be used;
// - after previous step, if value is a promise - router will wait for it to resolve; resolved value then will be used;
// otherwise value will be used directly.
resolve: PropTypes.objectOf(PropTypes.any),
})
),
onRouteChange: PropTypes.func,
};
Router.defaultProps = {
routes: [],
onRouteChange: () => {},
};

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import PropTypes from "prop-types";
import { Auth } from "@/services/auth";
export default function SignedOutPageWrapper({ apiKey, children }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
let isCancelled = false;
Auth.setApiKey(apiKey);
Auth.loadConfig()
.then(() => {
if (!isCancelled) {
setIsAuthenticated(true);
}
})
.catch(() => {
if (!isCancelled) {
setIsAuthenticated(false);
}
});
return () => {
isCancelled = true;
};
}, [apiKey]);
if (!isAuthenticated) {
return null;
}
return children;
}
SignedOutPageWrapper.propTypes = {
apiKey: PropTypes.string.isRequired,
children: PropTypes.node,
};
SignedOutPageWrapper.defaultProps = {
children: null,
};

View File

@@ -0,0 +1,29 @@
import { isString } from "lodash";
import navigateTo from "./navigateTo";
export default function handleNavigationIntent(event) {
let element = event.target;
while (element) {
if (element.tagName === "A") {
break;
}
element = element.parentNode;
}
if (!element || !element.hasAttribute("href")) {
return;
}
// Keep some default behaviour
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
const target = element.getAttribute("target");
if (isString(target) && target.toLowerCase() === "_blank") {
return;
}
event.preventDefault();
navigateTo(element.href);
}

View File

@@ -0,0 +1,37 @@
import React, { useState, useEffect } from "react";
import routes from "@/pages";
import Router from "./Router";
import handleNavigationIntent from "./handleNavigationIntent";
import ErrorMessage from "./ErrorMessage";
export default function ApplicationArea() {
const [currentRoute, setCurrentRoute] = useState(null);
const [unhandledError, setUnhandledError] = useState(null);
useEffect(() => {
if (currentRoute && currentRoute.title) {
document.title = currentRoute.title;
}
}, [currentRoute]);
useEffect(() => {
function globalErrorHandler(event) {
event.preventDefault();
setUnhandledError(event.error);
}
document.body.addEventListener("click", handleNavigationIntent, false);
window.addEventListener("error", globalErrorHandler, false);
return () => {
document.body.removeEventListener("click", handleNavigationIntent, false);
window.removeEventListener("error", globalErrorHandler, false);
};
}, []);
if (unhandledError) {
return <ErrorMessage error={unhandledError} showOriginalMessage={false} />;
}
return <Router routes={routes} onRouteChange={setCurrentRoute} />;
}

View File

@@ -0,0 +1,25 @@
import location from "@/services/location";
import url from "@/services/url";
import { stripBase } from "./Router";
// When `replace` is set to `true` - it will just replace current URL
// without reloading current page (router will skip this location change)
export default function navigateTo(href, replace = false) {
// Allow calling chain to roll up, and then navigate
setTimeout(() => {
const isExternal = stripBase(href) === false;
if (isExternal) {
window.location = href;
return;
}
href = url.parse(href);
location.update(
{
path: href.pathname,
search: href.search,
hash: href.hash,
},
replace
);
}, 10);
}

View File

@@ -1,6 +1,5 @@
import React from "react";
import PropTypes from "prop-types";
import { $rootScope } from "@/services/ng";
export default class FavoritesControl extends React.Component {
static propTypes = {
@@ -21,7 +20,6 @@ export default class FavoritesControl extends React.Component {
action().then(() => {
item.is_favorite = !savedIsFavorite;
this.forceUpdate();
$rootScope.$broadcast("reloadFavorites");
callback();
});
}

View File

@@ -16,7 +16,7 @@ import Form from "antd/lib/form";
import Tooltip from "antd/lib/tooltip";
import ParameterValueInput from "@/components/ParameterValueInput";
import { ParameterMappingType } from "@/services/widget";
import { Parameter } from "@/services/parameters";
import { Parameter, cloneParameter } from "@/services/parameters";
import HelpTrigger from "@/components/HelpTrigger";
import "./ParameterMappingInput.less";
@@ -42,7 +42,7 @@ export function parameterMappingsToEditableMappings(mappings, parameters, existi
break;
case ParameterMappingType.StaticValue:
result.type = MappingType.StaticValue;
result.param = result.param.clone();
result.param = cloneParameter(result.param);
result.param.setValue(result.value);
break;
case ParameterMappingType.WidgetLevel:
@@ -73,7 +73,7 @@ export function editableMappingsToParameterMappings(mappings) {
break;
case MappingType.StaticValue:
result.type = ParameterMappingType.StaticValue;
result.param = mapping.param.clone();
result.param = cloneParameter(mapping.param);
result.param.setValue(result.value);
result.value = result.param.value;
break;
@@ -157,7 +157,7 @@ export class ParameterMappingInput extends React.Component {
const { onChange, mapping } = this.props;
const newMapping = extend({}, mapping, update);
if (newMapping.value !== mapping.value) {
newMapping.param = newMapping.param.clone();
newMapping.param = cloneParameter(newMapping.param);
newMapping.param.setValue(newMapping.value);
}
if (has(update, "type")) {
@@ -527,7 +527,7 @@ export class ParameterMappingListInput extends React.Component {
// static type is different since it's fed param.normalizedValue
} else if (type === MappingType.StaticValue) {
param = param.clone().setValue(mapping.value);
param = cloneParameter(param).setValue(mapping.value);
}
let value = Parameter.getExecutionValue(param);

View File

@@ -2,8 +2,8 @@ import { size, filter, forEach, extend } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import { SortableContainer, SortableElement, DragHandle } from "@/components/sortable";
import { $location, $rootScope } from "@/services/ng";
import { Parameter } from "@/services/parameters";
import location from "@/services/location";
import { Parameter, createParameter } from "@/services/parameters";
import ParameterApplyButton from "@/components/ParameterApplyButton";
import ParameterValueInput from "@/components/ParameterValueInput";
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
@@ -12,13 +12,11 @@ import { toHuman } from "@/lib/utils";
import "./Parameters.less";
function updateUrl(parameters) {
const params = extend({}, $location.search());
const params = extend({}, location.search);
parameters.forEach(param => {
extend(params, param.toUrlParams());
});
Object.keys(params).forEach(key => params[key] == null && delete params[key]);
$location.search(params);
$rootScope.$applyAsync(); // needed for the url to update
location.setSearch(params, true);
}
export default class Parameters extends React.Component {
@@ -108,14 +106,16 @@ export default class Parameters extends React.Component {
showParameterSettings = (parameter, index) => {
const { onParametersEdit } = this.props;
EditParameterSettingsDialog.showModal({ parameter }).result.then(updated => {
this.setState(({ parameters }) => {
const updatedParameter = extend(parameter, updated);
parameters[index] = Parameter.create(updatedParameter, updatedParameter.parentQueryId);
onParametersEdit();
return { parameters };
});
});
EditParameterSettingsDialog.showModal({ parameter })
.result.then(updated => {
this.setState(({ parameters }) => {
const updatedParameter = extend(parameter, updated);
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
onParametersEdit();
return { parameters };
});
})
.catch(() => {}); // ignore dismiss
};
renderParameter(param, index) {

View File

@@ -1,7 +1,7 @@
import React from "react";
import Menu from "antd/lib/menu";
import PageHeader from "@/components/PageHeader";
import { $location } from "@/services/ng";
import location from "@/services/location";
import settingsMenu from "@/services/settingsMenu";
function wrapSettingsTab(options, WrappedComponent) {
@@ -10,7 +10,7 @@ function wrapSettingsTab(options, WrappedComponent) {
}
return function SettingsTab(props) {
const activeItem = settingsMenu.getActiveItem($location.path());
const activeItem = settingsMenu.getActiveItem(location.path);
return (
<div className="settings-screen">
<div className="container">

View File

@@ -11,7 +11,7 @@ export default function Layout({ activeTab, children }) {
<PageHeader title="Admin" />
<div className="bg-white tiled">
<Tabs className="admin-page-layout-tabs" defaultActiveKey={activeTab} animated={false}>
<Tabs className="admin-page-layout-tabs" defaultActiveKey={activeTab} animated={false} tabBarGutter={0}>
<Tabs.TabPane key="system_status" tab={<a href="admin/status">System Status</a>}>
{activeTab === "system_status" ? children : null}
</Tabs.TabPane>

View File

@@ -1,25 +0,0 @@
import PromiseRejectionError from "@/lib/promise-rejection-error";
// eslint-disable-next-line import/prefer-default-export
export class ErrorHandler {
constructor() {
this.logToConsole = true;
this.reset();
}
reset() {
this.error = null;
}
process(error) {
this.reset();
if (this.logToConsole) {
// Log raw error object
// eslint-disable-next-line no-console
console.error(error);
}
if (error === null || error instanceof PromiseRejectionError) {
this.error = error;
}
}
}

View File

@@ -1,100 +0,0 @@
import debug from "debug";
import { react2angular } from "react2angular";
import AppHeader from "@/components/app-header/AppHeader";
import PromiseRejectionError from "@/lib/promise-rejection-error";
import { ErrorHandler } from "./error-handler";
import template from "./template.html";
const logger = debug("redash:app-view");
const handler = new ErrorHandler();
const layouts = {
default: {
showHeader: true,
bodyClass: false,
},
fixed: {
showHeader: true,
bodyClass: "fixed-layout",
},
defaultSignedOut: {
showHeader: false,
},
};
function selectLayout(route) {
let layout = layouts.default;
if (route.layout) {
layout = layouts[route.layout] || layouts.default;
} else if (!route.authenticated) {
layout = layouts.defaultSignedOut;
}
return layout;
}
class AppViewComponent {
constructor($rootScope, $route, Auth) {
this.$rootScope = $rootScope;
this.layout = layouts.defaultSignedOut;
this.handler = handler;
$rootScope.$on("$routeChangeStart", (event, route) => {
this.handler.reset();
// In case we're handling $routeProvider.otherwise call, there will be no
// $$route.
const $$route = route.$$route || { authenticated: true };
if ($$route.authenticated) {
// For routes that need authentication, check if session is already
// loaded, and load it if not.
logger("Requested authenticated route: ", route);
if (!Auth.isAuthenticated()) {
event.preventDefault();
// Auth.requireSession resolves only if session loaded
Auth.requireSession().then(() => {
this.applyLayout($$route);
$route.reload();
});
}
}
});
$rootScope.$on("$routeChangeSuccess", (event, route) => {
const $$route = route.$$route || { authenticated: true };
this.applyLayout($$route);
if (route.title) {
document.title = route.title;
}
});
$rootScope.$on("$routeChangeError", (event, current, previous, rejection) => {
const $$route = current.$$route || { authenticated: true };
this.applyLayout($$route);
throw new PromiseRejectionError(rejection);
});
}
applyLayout(route) {
this.layout = selectLayout(route);
this.$rootScope.bodyClass = this.layout.bodyClass;
}
}
export default function init(ngModule) {
ngModule.factory(
"$exceptionHandler",
() =>
function exceptionHandler(exception) {
handler.process(exception);
}
);
ngModule.component("appHeader", react2angular(AppHeader));
ngModule.component("appView", {
template,
controller: AppViewComponent,
});
}

View File

@@ -1,16 +0,0 @@
<app-header ng-if="$ctrl.layout.showHeader"></app-header>
<div ng-if="$ctrl.handler.error" class="fixed-container" data-test="ErrorMessage">
<div class="container">
<div class="col-md-8 col-md-push-2">
<div class="error-state bg-white tiled">
<div class="error-state__icon">
<i class="zmdi zmdi-alert-circle-o"></i>
</div>
<div class="error-state__details">
<h4>{{ $ctrl.handler.error.message }}</h4>
</div>
</div>
</div>
</div>
</div>
<div id="app-content" ng-if="!$ctrl.handler.error" ng-view autoscroll></div>

View File

@@ -5,7 +5,7 @@ import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import DynamicComponent from "@/components/DynamicComponent";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { $location } from "@/services/ng";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import recordEvent from "@/services/recordEvent";
import { policy } from "@/services/policy";
@@ -39,10 +39,7 @@ function CreateDashboardDialog({ dialog }) {
axios.post("api/dashboards", { name }).then(data => {
dialog.close();
$location
.path(`/dashboard/${data.slug}`)
.search("edit")
.replace();
navigateTo(`dashboard/${data.slug}?edit`);
});
recordEvent("create", "dashboard");
}

View File

@@ -18,7 +18,7 @@ function TextboxWidget(props) {
setText(newText);
return widget.save();
},
});
}).result.catch(() => {}); // ignore dismiss
};
const TextboxMenuOptions = [

View File

@@ -215,7 +215,7 @@ class VisualizationWidget extends React.Component {
}
expandWidget = () => {
ExpandedWidgetDialog.showModal({ widget: this.props.widget });
ExpandedWidgetDialog.showModal({ widget: this.props.widget }).result.catch(() => {}); // ignore dismiss
};
editParameterMappings = () => {
@@ -223,14 +223,16 @@ class VisualizationWidget extends React.Component {
EditParameterMappingsDialog.showModal({
dashboard,
widget,
}).result.then(valuesChanged => {
// refresh widget if any parameter value has been updated
if (valuesChanged) {
onRefresh();
}
onParameterMappingsChange();
this.setState({ localParameters: widget.getLocalParameters() });
});
})
.result.then(valuesChanged => {
// refresh widget if any parameter value has been updated
if (valuesChanged) {
onRefresh();
}
onParameterMappingsChange();
this.setState({ localParameters: widget.getLocalParameters() });
})
.catch(() => {}); // ignore dismiss
};
renderVisualization() {

View File

@@ -1,5 +1,5 @@
import { keys, some } from "lodash";
import React from "react";
import React, { useCallback } from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
@@ -64,6 +64,10 @@ function EmptyState({
inviteUsers: organizationStatus.objectCounters.users > 1,
};
const showCreateDashboardDialog = useCallback(() => {
CreateDashboardDialog.showModal().result.catch(() => {}); // ignore dismiss
}, []);
// Show if `onboardingMode=false` or any requested step not completed
const shouldShow = !onboardingMode || some(keys(isAvailable), step => isAvailable[step] && !isCompleted[step]);
@@ -121,7 +125,7 @@ function EmptyState({
<Step
show={isAvailable.dashboard}
completed={isCompleted.dashboard}
onClick={() => CreateDashboardDialog.showModal()}
onClick={showCreateDashboardDialog}
urlText="Create"
text="your first Dashboard"
/>

View File

@@ -2,9 +2,7 @@ import { omit, debounce } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import hoistNonReactStatics from "hoist-non-react-statics";
import { $route, $routeParams } from "@/services/ng";
import { clientConfig } from "@/services/auth";
import { StateStorage } from "./classes/StateStorage";
export const ControllerType = PropTypes.shape({
// values of props declared by wrapped component, current route's locals (`resolve: { ... }`) and title
@@ -37,7 +35,7 @@ export const ControllerType = PropTypes.shape({
handleError: PropTypes.func.isRequired, // (error) => void
});
export function wrap(WrappedComponent, itemsSource, stateStorage) {
export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
class ItemsListWrapper extends React.Component {
static propTypes = {
...omit(WrappedComponent.propTypes, ["controller"]),
@@ -59,7 +57,10 @@ export function wrap(WrappedComponent, itemsSource, stateStorage) {
constructor(props) {
super(props);
stateStorage = stateStorage || new StateStorage();
const stateStorage = createStateStorage();
const itemsSource = createItemsSource();
this._itemsSource = itemsSource;
itemsSource.setState({ ...stateStorage.getState(), validate: false });
itemsSource.getCallbackContext = () => this.state;
@@ -94,9 +95,9 @@ export function wrap(WrappedComponent, itemsSource, stateStorage) {
}
componentWillUnmount() {
itemsSource.onBeforeUpdate = () => {};
itemsSource.onAfterUpdate = () => {};
itemsSource.onError = () => {};
this._itemsSource.onBeforeUpdate = () => {};
this._itemsSource.onAfterUpdate = () => {};
this._itemsSource.onError = () => {};
}
// eslint-disable-next-line class-methods-use-this
@@ -105,14 +106,11 @@ export function wrap(WrappedComponent, itemsSource, stateStorage) {
// Custom params from items source
...params,
// Add some properties of current route (`$resolve`, title, route params)
// ANGULAR_REMOVE_ME Revisit when some React router will be used
title: $route.current.title,
...$routeParams,
...omit($route.current.locals, ["$scope", "$template"]),
title: this.props.currentRoute.title,
...this.props.routeParams,
// Add to params all props except of own ones
...omit(this.props, ["onError", "children"]),
...omit(this.props, ["onError", "children", "currentRoute", "routeParams"]),
};
return {
...rest,

View File

@@ -141,11 +141,7 @@ export class ItemsSource {
handleError = error => {
if (isFunction(this.onError)) {
// ANGULAR_REMOVE_ME This code is related to Angular's HTTP services
if (error.status && error.data) {
error = new PromiseRejectionError(error);
}
this.onError(error);
this.onError(new PromiseRejectionError(error));
}
};
}

View File

@@ -1,6 +1,6 @@
import { defaults } from "lodash";
import { clientConfig } from "@/services/auth";
import { $location } from "@/services/ng";
import location from "@/services/location";
import { parse as parseOrderBy, compile as compileOrderBy } from "./Sorter";
export class StateStorage {
@@ -26,7 +26,7 @@ export class StateStorage {
export class UrlStateStorage extends StateStorage {
getState() {
const defaultState = super.getState();
const params = $location.search();
const params = location.search;
const searchTerm = params.q || "";
@@ -47,11 +47,14 @@ export class UrlStateStorage extends StateStorage {
// eslint-disable-next-line class-methods-use-this
setState({ page, itemsPerPage, orderByField, orderByReverse, searchTerm }) {
$location.search({
page,
page_size: itemsPerPage,
order: compileOrderBy(orderByField, orderByReverse),
q: searchTerm !== "" ? searchTerm : null,
});
location.setSearch(
{
page,
page_size: itemsPerPage,
order: compileOrderBy(orderByField, orderByReverse),
q: searchTerm !== "" ? searchTerm : null,
},
true
);
}
}

View File

@@ -6,7 +6,7 @@ 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 { axios } from "@/services/axios";
import { clientConfig } from "@/services/auth";
import notification from "@/services/notification";
@@ -18,13 +18,13 @@ function ApiKeyDialog({ dialog, ...props }) {
const regenerateQueryApiKey = useCallback(() => {
setUpdatingApiKey(true);
$http
axios
.post(`api/queries/${query.id}/regenerate_api_key`)
.success(data => {
.then(data => {
setUpdatingApiKey(false);
setQuery(extend(query.clone(), { api_key: data.api_key }));
})
.error(() => {
.catch(() => {
setUpdatingApiKey(false);
notification.error("Failed to update API key");
});

View File

@@ -24,7 +24,9 @@ export class TagsControl extends React.Component {
};
editTags = (tags, getAvailableTags) => {
EditTagsDialog.showModal({ tags, getAvailableTags }).result.then(this.props.onEdit);
EditTagsDialog.showModal({ tags, getAvailableTags })
.result.then(this.props.onEdit)
.catch(() => {}); // ignore dismiss
};
renderEditButton() {

View File

@@ -42,7 +42,7 @@ export default class UserEdit extends React.Component {
}
changePassword = () => {
ChangePasswordDialog.showModal({ user: this.props.user });
ChangePasswordDialog.showModal({ user: this.props.user }).result.catch(() => {}); // ignore dismiss
};
sendPasswordReset = () => {

View File

@@ -5,26 +5,18 @@ import "core-js/fn/typed/array-buffer";
import "@/assets/images/avatar.svg";
import * as Pace from "pace-progress";
import debug from "debug";
import angular from "angular";
import ngRoute from "angular-route";
import { each, isFunction, extend } from "lodash";
import initAppView from "@/components/app-view";
import DialogWrapper from "@/components/DialogWrapper";
import organizationStatus from "@/services/organizationStatus";
import { isFunction } from "lodash";
import url from "@/services/url";
import "./antd-spinner";
import moment from "moment";
const logger = debug("redash:config");
Pace.options.shouldHandlePushState = (prevUrl, newUrl) => {
// Show pace progress bar only if URL path changed; when query params
// or hash changed - ignore that history event
const [prevPrefix] = prevUrl.split("?");
const [newPrefix] = newUrl.split("?");
return prevPrefix !== newPrefix;
prevUrl = url.parse(prevUrl);
newUrl = url.parse(newUrl);
return prevUrl.pathname !== newUrl.pathname;
};
moment.updateLocale("en", {
@@ -45,10 +37,6 @@ moment.updateLocale("en", {
},
});
const requirements = [ngRoute];
const ngModule = angular.module("app", requirements);
function registerAll(context) {
const modules = context
.keys()
@@ -58,7 +46,7 @@ function registerAll(context) {
return modules
.filter(isFunction)
.filter(f => f.init)
.map(f => f(ngModule));
.map(f => f());
}
function requireImages() {
@@ -72,59 +60,11 @@ function registerExtensions() {
registerAll(context);
}
function registerServices() {
const context = require.context("@/services", true, /^((?![\\/.]test[\\./]).)*\.js$/);
registerAll(context);
}
function registerVisualizations() {
const context = require.context("@/visualizations", true, /^((?![\\/.]test[\\./]).)*\.jsx?$/);
registerAll(context);
}
function registerPages() {
const context = require.context("@/pages", true, /^((?![\\/.]test[\\./]).)*\.jsx?$/);
const routesCollection = registerAll(context);
routesCollection.forEach(routes => {
ngModule.config($routeProvider => {
each(routes, (route, path) => {
logger("Registering route: %s", path);
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);
});
});
});
ngModule.config($routeProvider => {
$routeProvider.otherwise({
resolve: {
// Ugly hack to show 404 when hitting an unknown route.
error: () => {
const error = { status: 404 };
throw error;
},
},
});
});
}
requireImages();
registerServices();
initAppView(ngModule);
registerPages();
registerExtensions();
registerVisualizations();
ngModule.run($q => {
DialogWrapper.Promise = $q;
});
export default ngModule;

View File

@@ -1,20 +1,20 @@
<!DOCTYPE html>
<html ng-app="app" ng-strict-di>
<head lang="en">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8">
<base href="/">
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="UTF-8" />
<base href="/" />
<title>Redash</title>
<script src="/static/unsupportedRedirect.js" async></script>
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png" />
</head>
<body ng-class="bodyClass">
<body>
<section>
<app-view></app-view>
<div id="application-root"></div>
<div class="loading-indicator">
<div id="css-logo">
<div id="circle">

View File

@@ -1,9 +1,11 @@
import ngModule from "@/config";
import React from "react";
import ReactDOM from "react-dom";
import ApplicationArea from "@/components/ApplicationArea";
ngModule.config(($locationProvider, $compileProvider) => {
$compileProvider.debugInfoEnabled(false);
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|data|tel|sms|mailto):/);
$locationProvider.html5Mode(true);
import "@/config";
import offlineListener from "@/services/offline-listener";
ReactDOM.render(<ApplicationArea />, document.getElementById("application-root"), () => {
offlineListener.init();
});
export default ngModule;

View File

@@ -1,7 +1,9 @@
import { isObject } from "lodash";
export default class PromiseRejectionError extends Error {
constructor(rejection) {
let message;
if (rejection.status !== undefined) {
if (isObject(rejection) && rejection.status !== undefined) {
if (rejection.status === 404) {
message = "It seems like the page you're looking for cannot be found.";
} else if (rejection.status === 403 || rejection.status === 401) {

View File

@@ -1,4 +1,3 @@
import { isObject, cloneDeep, each, extend } from "lodash";
import moment from "moment";
import { clientConfig } from "@/services/auth";
@@ -158,44 +157,3 @@ export function formatColumnValue(value, columnType = null) {
return value;
}
export function routesToAngularRoutes(routes, template) {
const result = {};
template = extend({}, template); // convert to object
each(routes, ({ path, title, key, ...resolve }) => {
// Convert to functions
each(resolve, (value, prop) => {
resolve[prop] = () => value;
});
result[path] = {
...template,
title,
// keep `resolve` from `template` (if exists)
resolve: {
...template.resolve,
...resolve,
currentPage: () => key,
},
};
});
return result;
}
// ANGULAR_REMOVE_ME
export function cleanAngularProps(value) {
// remove all props that start with '$$' - that's what `angular.toJson` does
const omitAngularProps = obj => {
each(obj, (v, k) => {
if (("" + k).startsWith("$$")) {
delete obj[k];
} else {
obj[k] = isObject(v) ? omitAngularProps(v) : v;
}
});
return obj;
};
const result = cloneDeep(value);
return isObject(result) ? omitAngularProps(result) : result;
}

View File

@@ -1,20 +1,20 @@
<!DOCTYPE html>
<html ng-app="app" ng-strict-di>
<head lang="en">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8">
<base href="{{base_href}}">
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="UTF-8" />
<base href="{{base_href}}" />
<title>Redash</title>
<script src="/static/unsupportedRedirect.js" async></script>
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png" />
</head>
<body ng-class="bodyClass">
<body>
<section>
<app-view></app-view>
<div id="application-root"></div>
<div class="loading-indicator">
<div id="css-logo">
<div id="circle">

View File

@@ -1,22 +1,22 @@
import { flatMap, values } from "lodash";
import React from "react";
import { axios } from "@/services/axios";
import { react2angular } from "react2angular";
import Alert from "antd/lib/alert";
import Tabs from "antd/lib/tabs";
import * as Grid from "antd/lib/grid";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import Layout from "@/components/admin/Layout";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import { CounterCard, WorkersTable, QueuesTable, OtherJobsTable } from "@/components/admin/RQStatus";
import { $location, $rootScope } from "@/services/ng";
import location from "@/services/location";
import recordEvent from "@/services/recordEvent";
import { routesToAngularRoutes } from "@/lib/utils";
import moment from "moment";
class Jobs extends React.Component {
state = {
activeTab: $location.hash(),
activeTab: location.hash,
isLoading: true,
error: null,
@@ -82,8 +82,7 @@ class Jobs extends React.Component {
const { isLoading, error, queueCounters, startedJobs, overallCounters, workers, activeTab } = this.state;
const changeTab = newTab => {
$location.hash(newTab);
$rootScope.$applyAsync();
location.setHash(newTab);
this.setState({ activeTab: newTab });
};
@@ -122,21 +121,14 @@ class Jobs extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component("pageJobs", react2angular(Jobs));
return routesToAngularRoutes(
[
{
path: "/admin/queries/jobs",
title: "RQ Status",
key: "jobs",
},
],
{
template: "<page-jobs></page-jobs>",
}
);
}
init.init = true;
export default {
path: "/admin/queries/jobs",
title: "RQ Status",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <Jobs {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
};

View File

@@ -1,15 +1,16 @@
import { map } from "lodash";
import React from "react";
import { axios } from "@/services/axios";
import { react2angular } from "react2angular";
import Switch from "antd/lib/switch";
import * as Grid from "antd/lib/grid";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import Paginator from "@/components/Paginator";
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
import SchedulePhrase from "@/components/queries/SchedulePhrase";
import TimeAgo from "@/components/TimeAgo";
import Layout from "@/components/admin/Layout";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ItemsSource } from "@/components/items-list/classes/ItemsSource";
@@ -21,7 +22,6 @@ import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTab
import { Query } from "@/services/query";
import recordEvent from "@/services/recordEvent";
import { routesToAngularRoutes } from "@/lib/utils";
class OutdatedQueries extends React.Component {
static propTypes = {
@@ -147,51 +147,43 @@ class OutdatedQueries extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component(
"pageOutdatedQueries",
react2angular(
itemsList(
OutdatedQueries,
new ItemsSource({
doRequest(request, context) {
return (
axios
.get("/api/admin/queries/outdated")
// eslint-disable-next-line camelcase
.then(({ queries, updated_at }) => {
context.setCustomParams({ lastUpdatedAt: parseFloat(updated_at) });
return queries;
})
);
},
processResults(items) {
return map(items, item => new Query(item));
},
isPlainList: true,
}),
new StateStorage({ orderByField: "created_at", orderByReverse: true })
)
)
);
return routesToAngularRoutes(
[
{
path: "/admin/queries/outdated",
title: "Outdated Queries",
key: "outdated_queries",
const OutdatedQueriesPage = itemsList(
OutdatedQueries,
() =>
new ItemsSource({
doRequest(request, context) {
return (
axios
.get("/api/admin/queries/outdated")
// eslint-disable-next-line camelcase
.then(({ queries, updated_at }) => {
context.setCustomParams({ lastUpdatedAt: parseFloat(updated_at) });
return queries;
})
);
},
],
{
template: '<page-outdated-queries on-error="handleError"></page-outdated-queries>',
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
processResults(items) {
return map(items, item => new Query(item));
},
}
);
}
isPlainList: true,
}),
() => new StateStorage({ orderByField: "created_at", orderByReverse: true })
);
init.init = true;
export default {
path: "/admin/queries/outdated",
title: "Outdated Queries",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<OutdatedQueriesPage
routeParams={{ ...currentRoute.routeParams, currentPage: "outdated_queries" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
};

View File

@@ -2,14 +2,14 @@ import { omit } from "lodash";
import React from "react";
import { axios } from "@/services/axios";
import PropTypes from "prop-types";
import { react2angular } from "react2angular";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import Layout from "@/components/admin/Layout";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import * as StatusBlock from "@/components/admin/StatusBlock";
import recordEvent from "@/services/recordEvent";
import PromiseRejectionError from "@/lib/promise-rejection-error";
import { routesToAngularRoutes } from "@/lib/utils";
import "./system-status.less";
@@ -56,11 +56,7 @@ class SystemStatus extends React.Component {
});
})
.catch(error => {
// ANGULAR_REMOVE_ME This code is related to Angular's HTTP services
if (error.status && error.data) {
error = new PromiseRejectionError(error);
}
this.props.onError(error);
this.props.onError(new PromiseRejectionError(error));
});
this._refreshTimer = setTimeout(this.refresh, 60 * 1000);
};
@@ -89,26 +85,14 @@ class SystemStatus extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component("pageSystemStatus", react2angular(SystemStatus));
return routesToAngularRoutes(
[
{
path: "/admin/status",
title: "System Status",
key: "system_status",
},
],
{
template: '<page-system-status on-error="handleError"></page-system-status>',
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
}
);
}
init.init = true;
export default {
path: "/admin/status",
title: "System Status",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <SystemStatus {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
};

View File

@@ -1,10 +1,11 @@
import { head, includes, trim, template, values } from "lodash";
import React from "react";
import { react2angular } from "react2angular";
import { head, includes, trim, template } from "lodash";
import PropTypes from "prop-types";
import { $route } from "@/services/ng";
import { currentUser } from "@/services/auth";
import navigateTo from "@/services/navigateTo";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import notification from "@/services/notification";
import AlertService from "@/services/alert";
import { Query as QueryService } from "@/services/query";
@@ -15,7 +16,6 @@ import AlertView from "./AlertView";
import AlertEdit from "./AlertEdit";
import AlertNew from "./AlertNew";
import { routesToAngularRoutes } from "@/lib/utils";
import PromiseRejectionError from "@/lib/promise-rejection-error";
const MODES = {
@@ -34,6 +34,18 @@ export function getDefaultName(alert) {
}
class AlertPage extends React.Component {
static propTypes = {
mode: PropTypes.oneOf(values(MODES)),
alertId: PropTypes.string,
onError: PropTypes.func,
};
static defaultProps = {
mode: null,
alertId: null,
onError: () => {},
};
_isMounted = false;
state = {
@@ -46,7 +58,7 @@ class AlertPage extends React.Component {
componentDidMount() {
this._isMounted = true;
const { mode } = $route.current.locals;
const { mode } = this.props;
this.setState({ mode });
if (mode === MODES.NEW) {
@@ -62,7 +74,7 @@ class AlertPage extends React.Component {
canEdit: true,
});
} else {
const { alertId } = $route.current.params;
const { alertId } = this.props;
AlertService.get({ id: alertId })
.then(alert => {
if (this._isMounted) {
@@ -84,7 +96,7 @@ class AlertPage extends React.Component {
})
.catch(err => {
if (this._isMounted) {
throw new PromiseRejectionError(err);
this.props.onError(new PromiseRejectionError(err));
}
});
}
@@ -103,7 +115,7 @@ class AlertPage extends React.Component {
return AlertService.save(alert)
.then(alert => {
notification.success("Saved.");
navigateTo(`/alerts/${alert.id}`, true, false);
navigateTo(`alerts/${alert.id}`, true);
this.setState({ alert, mode: MODES.VIEW });
})
.catch(() => {
@@ -159,7 +171,7 @@ class AlertPage extends React.Component {
return AlertService.delete(alert)
.then(() => {
notification.success("Alert deleted successfully.");
navigateTo("/alerts");
navigateTo("alerts");
})
.catch(() => {
notification.error("Failed deleting alert.");
@@ -192,13 +204,13 @@ class AlertPage extends React.Component {
edit = () => {
const { id } = this.state.alert;
navigateTo(`/alerts/${id}/edit`, true, false);
navigateTo(`alerts/${id}/edit`, true);
this.setState({ mode: MODES.EDIT });
};
cancel = () => {
const { id } = this.state.alert;
navigateTo(`/alerts/${id}`, true, false);
navigateTo(`alerts/${id}`, true);
this.setState({ mode: MODES.VIEW });
};
@@ -229,47 +241,51 @@ class AlertPage extends React.Component {
};
return (
<div className="container alert-page">
{mode === MODES.NEW && <AlertNew {...commonProps} />}
{mode === MODES.VIEW && (
<AlertView canEdit={canEdit} onEdit={this.edit} muted={muted} unmute={this.unmute} {...commonProps} />
)}
{mode === MODES.EDIT && <AlertEdit cancel={this.cancel} {...commonProps} />}
<div className="alert-page">
<div className="container">
{mode === MODES.NEW && <AlertNew {...commonProps} />}
{mode === MODES.VIEW && (
<AlertView canEdit={canEdit} onEdit={this.edit} muted={muted} unmute={this.unmute} {...commonProps} />
)}
{mode === MODES.EDIT && <AlertEdit cancel={this.cancel} {...commonProps} />}
</div>
</div>
);
}
}
export default function init(ngModule) {
ngModule.component("alertPage", react2angular(AlertPage));
return routesToAngularRoutes(
[
{
path: "/alerts/new",
title: "New Alert",
mode: MODES.NEW,
},
{
path: "/alerts/:alertId",
title: "Alert",
mode: MODES.VIEW,
},
{
path: "/alerts/:alertId/edit",
title: "Alert",
mode: MODES.EDIT,
},
],
{
template: "<alert-page></alert-page>",
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
}
);
}
init.init = true;
export default [
{
path: "/alerts/new",
title: "New Alert",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <AlertPage {...currentRoute.routeParams} mode={MODES.NEW} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/alerts/:alertId([0-9]+)",
title: "Alert",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <AlertPage {...currentRoute.routeParams} mode={MODES.VIEW} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/alerts/:alertId([0-9]+)/edit",
title: "Alert",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <AlertPage {...currentRoute.routeParams} mode={MODES.EDIT} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
];

View File

@@ -127,7 +127,7 @@ export default class AlertDestinations extends React.Component {
notification.error("Failed saving subscription.");
});
},
});
}).result.catch(() => {}); // ignore dismiss
};
onUserEmailToggle = sub => {

View File

@@ -1,11 +1,10 @@
import React from "react";
import { react2angular } from "react2angular";
import { toUpper } from "lodash";
import React from "react";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import PageHeader from "@/components/PageHeader";
import Paginator from "@/components/Paginator";
import EmptyState from "@/components/empty-state/EmptyState";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import { wrap as liveItemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
import { StateStorage } from "@/components/items-list/classes/StateStorage";
@@ -14,7 +13,6 @@ import LoadingState from "@/components/items-list/components/LoadingState";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import Alert from "@/services/alert";
import { routesToAngularRoutes } from "@/lib/utils";
export const STATE_CLASS = {
unknown: "label-warning",
@@ -70,79 +68,73 @@ class AlertsList extends React.Component {
const { controller } = this.props;
return (
<div className="container">
<PageHeader title={controller.params.title} />
<div className="m-l-15 m-r-15">
{!controller.isLoaded && <LoadingState className="" />}
{controller.isLoaded && controller.isEmpty && (
<EmptyState
icon="fa fa-bell-o"
illustration="alert"
description="Get notified on certain events"
helpLink="https://redash.io/help/user-guide/alerts/"
showAlertStep
/>
)}
{controller.isLoaded && !controller.isEmpty && (
<div className="table-responsive bg-white tiled">
<ItemsTable
items={controller.pageItems}
columns={this.listColumns}
orderByField={controller.orderByField}
orderByReverse={controller.orderByReverse}
toggleSorting={controller.toggleSorting}
<div className="page-alerts-list">
<div className="container">
<PageHeader title={controller.params.title} />
<div className="m-l-15 m-r-15">
{!controller.isLoaded && <LoadingState className="" />}
{controller.isLoaded && controller.isEmpty && (
<EmptyState
icon="fa fa-bell-o"
illustration="alert"
description="Get notified on certain events"
helpLink="https://redash.io/help/user-guide/alerts/"
showAlertStep
/>
<Paginator
totalCount={controller.totalItemsCount}
itemsPerPage={controller.itemsPerPage}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
</div>
)}
)}
{controller.isLoaded && !controller.isEmpty && (
<div className="table-responsive bg-white tiled">
<ItemsTable
items={controller.pageItems}
columns={this.listColumns}
orderByField={controller.orderByField}
orderByReverse={controller.orderByReverse}
toggleSorting={controller.toggleSorting}
/>
<Paginator
totalCount={controller.totalItemsCount}
itemsPerPage={controller.itemsPerPage}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
</div>
)}
</div>
</div>
</div>
);
}
}
export default function init(ngModule) {
ngModule.component(
"pageAlertsList",
react2angular(
liveItemsList(
AlertsList,
new ResourceItemsSource({
isPlainList: true,
getRequest() {
return {};
},
getResource() {
return Alert.query.bind(Alert);
},
}),
new StateStorage({ orderByField: "created_at", orderByReverse: true, itemsPerPage: 20 })
)
)
);
return routesToAngularRoutes(
[
{
path: "/alerts",
title: "Alerts",
key: "alerts",
const AlertsListPage = liveItemsList(
AlertsList,
() =>
new ResourceItemsSource({
isPlainList: true,
getRequest() {
return {};
},
],
{
reloadOnSearch: false,
template: '<page-alerts-list on-error="handleError"></page-alerts-list>',
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
getResource() {
return Alert.query.bind(Alert);
},
}
);
}
}),
() => new StateStorage({ orderByField: "created_at", orderByReverse: true, itemsPerPage: 20 })
);
init.init = true;
export default {
path: "/alerts",
title: "Alerts",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<AlertsListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "alerts" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
};

View File

@@ -1,6 +1,6 @@
import React from "react";
import { react2angular } from "react2angular";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import PageHeader from "@/components/PageHeader";
import Paginator from "@/components/Paginator";
import { DashboardTagsControl } from "@/components/tags-control/TagsControl";
@@ -8,7 +8,7 @@ import { DashboardTagsControl } from "@/components/tags-control/TagsControl";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
import { UrlStateStorage } from "@/components/items-list/classes/StateStorage";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import LoadingState from "@/components/items-list/components/LoadingState";
import * as Sidebar from "@/components/items-list/components/Sidebar";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
@@ -16,7 +16,6 @@ import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTab
import Layout from "@/components/layouts/ContentWithSidebar";
import { Dashboard } from "@/services/dashboard";
import { routesToAngularRoutes } from "@/lib/utils";
import DashboardListEmptyState from "./DashboardListEmptyState";
@@ -75,106 +74,113 @@ class DashboardList extends React.Component {
render() {
const { controller } = this.props;
return (
<div className="container">
<PageHeader title={controller.params.title} />
<Layout className="m-l-15 m-r-15">
<Layout.Sidebar className="m-b-0">
<Sidebar.SearchInput
placeholder="Search Dashboards..."
value={controller.searchTerm}
onChange={controller.updateSearch}
/>
<Sidebar.Menu items={this.sidebarMenu} selected={controller.params.currentPage} />
<Sidebar.Tags url="api/dashboards/tags" onChange={controller.updateSelectedTags} />
<Sidebar.PageSizeSelect
className="m-b-10"
options={controller.pageSizeOptions}
value={controller.itemsPerPage}
onChange={itemsPerPage => controller.updatePagination({ itemsPerPage })}
/>
</Layout.Sidebar>
<Layout.Content>
{controller.isLoaded ? (
<div data-test="DashboardLayoutContent">
{controller.isEmpty ? (
<DashboardListEmptyState
page={controller.params.currentPage}
searchTerm={controller.searchTerm}
selectedTags={controller.selectedTags}
/>
) : (
<div className="bg-white tiled table-responsive">
<ItemsTable
items={controller.pageItems}
columns={this.listColumns}
orderByField={controller.orderByField}
orderByReverse={controller.orderByReverse}
toggleSorting={controller.toggleSorting}
<div className="page-dashboard-list">
<div className="container">
<PageHeader title={controller.params.title} />
<Layout className="m-l-15 m-r-15">
<Layout.Sidebar className="m-b-0">
<Sidebar.SearchInput
placeholder="Search Dashboards..."
value={controller.searchTerm}
onChange={controller.updateSearch}
/>
<Sidebar.Menu items={this.sidebarMenu} selected={controller.params.currentPage} />
<Sidebar.Tags url="api/dashboards/tags" onChange={controller.updateSelectedTags} />
<Sidebar.PageSizeSelect
className="m-b-10"
options={controller.pageSizeOptions}
value={controller.itemsPerPage}
onChange={itemsPerPage => controller.updatePagination({ itemsPerPage })}
/>
</Layout.Sidebar>
<Layout.Content>
{controller.isLoaded ? (
<div data-test="DashboardLayoutContent">
{controller.isEmpty ? (
<DashboardListEmptyState
page={controller.params.currentPage}
searchTerm={controller.searchTerm}
selectedTags={controller.selectedTags}
/>
<Paginator
totalCount={controller.totalItemsCount}
itemsPerPage={controller.itemsPerPage}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
</div>
)}
</div>
) : (
<LoadingState />
)}
</Layout.Content>
</Layout>
) : (
<div className="bg-white tiled table-responsive">
<ItemsTable
items={controller.pageItems}
columns={this.listColumns}
orderByField={controller.orderByField}
orderByReverse={controller.orderByReverse}
toggleSorting={controller.toggleSorting}
/>
<Paginator
totalCount={controller.totalItemsCount}
itemsPerPage={controller.itemsPerPage}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
</div>
)}
</div>
) : (
<LoadingState />
)}
</Layout.Content>
</Layout>
</div>
</div>
);
}
}
export default function init(ngModule) {
ngModule.component(
"pageDashboardList",
react2angular(
itemsList(
DashboardList,
new ResourceItemsSource({
getResource({ params: { currentPage } }) {
return {
all: Dashboard.query.bind(Dashboard),
favorites: Dashboard.favorites.bind(Dashboard),
}[currentPage];
},
getItemProcessor() {
return item => new Dashboard(item);
},
}),
new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
)
)
);
return routesToAngularRoutes(
[
{
path: "/dashboards",
title: "Dashboards",
key: "all",
const DashboardListPage = itemsList(
DashboardList,
() =>
new ResourceItemsSource({
getResource({ params: { currentPage } }) {
return {
all: Dashboard.query.bind(Dashboard),
favorites: Dashboard.favorites.bind(Dashboard),
}[currentPage];
},
{
path: "/dashboards/favorites",
title: "Favorite Dashboards",
key: "favorites",
getItemProcessor() {
return item => new Dashboard(item);
},
],
{
reloadOnSearch: false,
template: '<page-dashboard-list on-error="handleError"></page-dashboard-list>',
controller($scope, $exceptionHandler) {
"ngInject";
}),
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
);
$scope.handleError = $exceptionHandler;
},
}
);
}
init.init = true;
export default [
{
path: "/dashboards",
title: "Dashboards",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<DashboardListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "all" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/dashboards/favorites",
title: "Favorite Dashboards",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<DashboardListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "favorites" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
];

View File

@@ -1,8 +1,7 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import { map, isEmpty, includes } from "lodash";
import { react2angular } from "react2angular";
import Button from "antd/lib/button";
import Checkbox from "antd/lib/checkbox";
import Dropdown from "antd/lib/dropdown";
@@ -10,15 +9,16 @@ import Menu from "antd/lib/menu";
import Icon from "antd/lib/icon";
import Modal from "antd/lib/modal";
import Tooltip from "antd/lib/tooltip";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import DashboardGrid from "@/components/dashboards/DashboardGrid";
import FavoritesControl from "@/components/FavoritesControl";
import EditInPlace from "@/components/EditInPlace";
import { DashboardTagsControl } from "@/components/tags-control/TagsControl";
import Parameters from "@/components/Parameters";
import Filters from "@/components/Filters";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import { Dashboard } from "@/services/dashboard";
import recordEvent from "@/services/recordEvent";
import { $route } from "@/services/ng";
import getTags from "@/services/getTags";
import { clientConfig } from "@/services/auth";
import { policy } from "@/services/policy";
@@ -378,32 +378,45 @@ DashboardComponent.propTypes = {
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
function DashboardPage() {
function DashboardPage({ dashboardSlug, onError }) {
const [dashboard, setDashboard] = useState(null);
const onErrorRef = useRef();
onErrorRef.current = onError;
useEffect(() => {
Dashboard.get({ slug: $route.current.params.dashboardSlug })
Dashboard.get({ slug: dashboardSlug })
.then(dashboardData => {
recordEvent("view", "dashboard", dashboardData.id);
setDashboard(dashboardData);
})
.catch(error => {
throw new PromiseRejectionError(error);
onErrorRef.current(new PromiseRejectionError(error));
});
}, []);
}, [dashboardSlug]);
return <div className="container">{dashboard && <DashboardComponent dashboard={dashboard} />}</div>;
return (
<div className="dashboard-page">
<div className="container">{dashboard && <DashboardComponent dashboard={dashboard} />}</div>
</div>
);
}
export default function init(ngModule) {
ngModule.component("dashboardPage", react2angular(DashboardPage));
DashboardPage.propTypes = {
dashboardSlug: PropTypes.string.isRequired,
onError: PropTypes.func,
};
return {
"/dashboard/:dashboardSlug": {
template: "<dashboard-page></dashboard-page>",
reloadOnSearch: false,
},
};
}
DashboardPage.defaultProps = {
onError: PropTypes.func,
};
init.init = true;
export default {
path: "/dashboard/:dashboardSlug",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <DashboardPage {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
};

View File

@@ -1,12 +1,13 @@
@import '../../assets/less/inc/variables';
@import '../../components/app-header/AppHeader.less';
@import "~@/assets/less/inc/variables";
@import "~@/components/ApplicationArea/ApplicationHeader/index.less";
/****
grid bg - based on 6 cols, 35px rows and 15px spacing
****/
// let the bg go all the way to the bottom
dashboard-page, dashboard-page .container {
.dashboard-page,
.dashboard-page .container {
display: flex;
flex-grow: 1;
flex-direction: column;
@@ -22,7 +23,7 @@ dashboard-page, dashboard-page .container {
.dashboard-header {
padding: 0 15px !important;
margin: 0 0 10px !important;
position: -webkit-sticky; // required for Safari
position: -webkit-sticky; // required for Safari
position: sticky;
background: #f6f7f9;
z-index: 99;
@@ -81,26 +82,27 @@ dashboard-page, dashboard-page .container {
width: 45px;
&:after {
content: '';
content: "";
animation: saving 2s linear infinite;
}
}
&[data-error] {
color: #F44336;
color: #f44336;
}
}
}
@keyframes saving {
0%, 100% {
content: '.';
0%,
100% {
content: ".";
}
33% {
content: '..';
content: "..";
}
66% {
content: '...';
content: "...";
}
}
@@ -111,7 +113,7 @@ dashboard-page, dashboard-page .container {
position: fixed;
left: 15px;
bottom: 20px;
width: calc(~'100% - 30px');
width: calc(~"100% - 30px");
z-index: 99;
box-shadow: fade(@redash-gray, 50%) 0px 7px 29px -3px;
display: flex;

View File

@@ -1,14 +1,14 @@
import React from "react";
import { isEmpty } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import { react2angular } from "react2angular";
import SignedOutPageWrapper from "@/components/ApplicationArea/SignedOutPageWrapper";
import BigMessage from "@/components/BigMessage";
import PageHeader from "@/components/PageHeader";
import Parameters from "@/components/Parameters";
import DashboardGrid from "@/components/dashboards/DashboardGrid";
import Filters from "@/components/Filters";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import { Dashboard } from "@/services/dashboard";
import { $route as ngRoute } from "@/services/ng";
import PromiseRejectionError from "@/lib/promise-rejection-error";
import logoUrl from "@/assets/images/redash_icon_small.png";
import useDashboard from "./useDashboard";
@@ -53,16 +53,25 @@ PublicDashboard.propTypes = {
};
class PublicDashboardPage extends React.Component {
static propTypes = {
token: PropTypes.string.isRequired,
onError: PropTypes.func,
};
static defaultProps = {
onError: () => {},
};
state = {
loading: true,
dashboard: null,
};
componentDidMount() {
Dashboard.getByToken({ token: ngRoute.current.params.token })
Dashboard.getByToken({ token: this.props.token })
.then(dashboard => this.setState({ dashboard, loading: false }))
.catch(error => {
throw new PromiseRejectionError(error);
this.props.onError(new PromiseRejectionError(error));
});
}
@@ -90,24 +99,14 @@ class PublicDashboardPage extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component("publicDashboardPage", react2angular(PublicDashboardPage));
return {
"/public/dashboards/:token": {
authenticated: false,
template: "<public-dashboard-page></public-dashboard-page>",
reloadOnSearch: false,
resolve: {
session: ($route, Auth) => {
"ngInject";
const token = $route.current.params.token;
Auth.setApiKey(token);
return Auth.loadConfig();
},
},
},
};
}
init.init = true;
export default {
path: "/public/dashboards/:token",
authenticated: false,
render: currentRoute => (
<SignedOutPageWrapper key={currentRoute.key} apiKey={currentRoute.routeParams.token}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <PublicDashboardPage {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</SignedOutPageWrapper>
),
};

View File

@@ -18,7 +18,7 @@ import {
min,
} from "lodash";
import notification from "@/services/notification";
import { $location, $rootScope } from "@/services/ng";
import location from "@/services/location";
import { Dashboard, collectDashboardFilters } from "@/services/dashboard";
import { currentUser } from "@/services/auth";
import recordEvent from "@/services/recordEvent";
@@ -35,11 +35,6 @@ export const DashboardStatusEnum = {
SAVING_FAILED: "saving_failed",
};
function updateUrlSearch(...params) {
$location.search(...params);
$rootScope.$applyAsync();
}
function getAffectedWidgets(widgets, updatedParameters = []) {
return !isEmpty(updatedParameters)
? widgets.filter(widget =>
@@ -69,15 +64,15 @@ function getLimitedRefreshRate(refreshRate) {
}
function getRefreshRateFromUrl() {
const refreshRate = parseFloat($location.search().refresh);
const refreshRate = parseFloat(location.search.refresh);
return isNaN(refreshRate) ? null : getLimitedRefreshRate(refreshRate);
}
function useFullscreenHandler() {
const [fullscreen, setFullscreen] = useState(has($location.search(), "fullscreen"));
const [fullscreen, setFullscreen] = useState(has(location.search, "fullscreen"));
useEffect(() => {
document.querySelector("body").classList.toggle("headless", fullscreen);
updateUrlSearch("fullscreen", fullscreen ? true : null);
document.body.classList.toggle("headless", fullscreen);
location.setSearch({ fullscreen: fullscreen ? true : null }, true);
}, [fullscreen]);
const toggleFullscreen = () => setFullscreen(!fullscreen);
@@ -88,7 +83,7 @@ function useRefreshRateHandler(refreshDashboard) {
const [refreshRate, setRefreshRate] = useState(getRefreshRateFromUrl());
useEffect(() => {
updateUrlSearch("refresh", refreshRate || null);
location.setSearch({ refresh: refreshRate || null }, true);
if (refreshRate) {
const refreshTimer = setInterval(refreshDashboard, refreshRate * 1000);
return () => clearInterval(refreshTimer);
@@ -99,13 +94,13 @@ function useRefreshRateHandler(refreshDashboard) {
}
function useEditModeHandler(canEditDashboard, widgets) {
const [editingLayout, setEditingLayout] = useState(canEditDashboard && has($location.search(), "edit"));
const [editingLayout, setEditingLayout] = useState(canEditDashboard && has(location.search, "edit"));
const [dashboardStatus, setDashboardStatus] = useState(DashboardStatusEnum.SAVED);
const [recentPositions, setRecentPositions] = useState([]);
const [doneBtnClickedWhileSaving, setDoneBtnClickedWhileSaving] = useState(false);
useEffect(() => {
updateUrlSearch("edit", editingLayout ? true : null);
location.setSearch({ edit: editingLayout ? true : null }, true);
}, [editingLayout]);
useEffect(() => {
@@ -206,7 +201,7 @@ function useDashboard(dashboardData) {
aclUrl,
context: "dashboard",
author: dashboard.user,
});
}).result.catch(() => {}); // ignore dismiss
}, [dashboard]);
const updateDashboard = useCallback(
@@ -266,7 +261,7 @@ function useDashboard(dashboardData) {
return Promise.all(loadWidgetPromises).then(() => {
const queryResults = compact(map(dashboard.widgets, widget => widget.getQueryResult()));
const updatedFilters = collectDashboardFilters(dashboard, queryResults, $location.search());
const updatedFilters = collectDashboardFilters(dashboard, queryResults, location.search);
setFilters(updatedFilters);
});
},
@@ -292,7 +287,9 @@ function useDashboard(dashboardData) {
ShareDashboardDialog.showModal({
dashboard,
hasOnlySafeQueries,
}).result.finally(() => setDashboard(currentDashboard => extend({}, currentDashboard)));
})
.result.catch(() => {}) // ignore dismiss
.finally(() => setDashboard(currentDashboard => extend({}, currentDashboard)));
}, [dashboard, hasOnlySafeQueries]);
const showAddTextboxDialog = useCallback(() => {
@@ -300,7 +297,7 @@ function useDashboard(dashboardData) {
dashboard,
onConfirm: text =>
dashboard.addWidget(text).then(() => setDashboard(currentDashboard => extend({}, currentDashboard))),
});
}).result.catch(() => {}); // ignore dismiss
}, [dashboard]);
const showAddWidgetDialog = useCallback(() => {
@@ -320,7 +317,7 @@ function useDashboard(dashboardData) {
setDashboard(currentDashboard => extend({}, currentDashboard))
);
}),
});
}).result.catch(() => {}); // ignore dismiss
}, [dashboard]);
const [refreshRate, setRefreshRate, disableRefreshRate] = useRefreshRateHandler(refreshDashboard);

View File

@@ -1,21 +1,32 @@
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import { react2angular } from "react2angular";
import { isEmpty, get } from "lodash";
import { isEmpty } from "lodash";
import DataSource, { IMG_ROOT } from "@/services/data-source";
import { policy } from "@/services/policy";
import navigateTo from "@/services/navigateTo";
import { $route } from "@/services/ng";
import { routesToAngularRoutes } from "@/lib/utils";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import CardsList from "@/components/cards-list/CardsList";
import LoadingState from "@/components/items-list/components/LoadingState";
import CreateSourceDialog from "@/components/CreateSourceDialog";
import DynamicComponent from "@/components/DynamicComponent";
import helper from "@/components/dynamic-form/dynamicFormHelper";
import wrapSettingsTab from "@/components/SettingsWrapper";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import recordEvent from "@/services/recordEvent";
import PromiseRejectionError from "@/lib/promise-rejection-error";
class DataSourcesList extends React.Component {
static propTypes = {
isNewDataSourcePage: PropTypes.bool,
onError: PropTypes.func,
};
static defaultProps = {
isNewDataSourcePage: false,
onError: () => {},
};
state = {
dataSourceTypes: [],
dataSources: [],
@@ -25,25 +36,27 @@ class DataSourcesList extends React.Component {
newDataSourceDialog = null;
componentDidMount() {
Promise.all([DataSource.query(), DataSource.types()]).then(values =>
this.setState(
{
dataSources: values[0],
dataSourceTypes: values[1],
loading: false,
},
() => {
// all resources are loaded in state
if ($route.current.locals.isNewDataSourcePage) {
if (policy.canCreateDataSource()) {
this.showCreateSourceDialog();
} else {
navigateTo("/data_sources");
Promise.all([DataSource.query(), DataSource.types()])
.then(values =>
this.setState(
{
dataSources: values[0],
dataSourceTypes: values[1],
loading: false,
},
() => {
// all resources are loaded in state
if (this.props.isNewDataSourcePage) {
if (policy.canCreateDataSource()) {
this.showCreateSourceDialog();
} else {
navigateTo("data_sources", true);
}
}
}
}
)
)
);
.catch(error => this.props.onError(new PromiseRejectionError(error)));
}
componentWillUnmount() {
@@ -62,12 +75,7 @@ class DataSourcesList extends React.Component {
DataSource.query().then(dataSources => this.setState({ dataSources, loading: false }));
return dataSource;
})
.catch(error => {
if (!(error instanceof Error)) {
error = new Error(get(error, "data.message", "Failed saving."));
}
return Promise.reject(error);
});
.catch(error => Promise.reject(new PromiseRejectionError(error)));
};
showCreateSourceDialog = () => {
@@ -88,6 +96,7 @@ class DataSourcesList extends React.Component {
}
})
.catch(() => {
navigateTo("data_sources", true);
this.newDataSourceDialog = null;
});
};
@@ -139,45 +148,39 @@ class DataSourcesList extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component(
"pageDataSourcesList",
react2angular(
wrapSettingsTab(
{
permission: "admin",
title: "Data Sources",
path: "data_sources",
order: 1,
},
DataSourcesList
)
)
);
const DataSourcesListPage = wrapSettingsTab(
{
permission: "admin",
title: "Data Sources",
path: "data_sources",
order: 1,
},
DataSourcesList
);
return routesToAngularRoutes(
[
{
path: "/data_sources",
title: "Data Sources",
key: "data_sources",
},
{
path: "/data_sources/new",
title: "Data Sources",
key: "data_sources",
isNewDataSourcePage: true,
},
],
{
template: "<page-data-sources-list></page-data-sources-list>",
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
}
);
}
init.init = true;
export default [
{
path: "/data_sources",
title: "Data Sources",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <DataSourcesListPage {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/data_sources/new",
title: "Data Sources",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<DataSourcesListPage {...currentRoute.routeParams} isNewDataSourcePage onError={handleError} />
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
];

View File

@@ -1,11 +1,10 @@
import React from "react";
import PropTypes from "prop-types";
import { get, find, toUpper } from "lodash";
import { react2angular } from "react2angular";
import Modal from "antd/lib/modal";
import DataSource, { IMG_ROOT } from "@/services/data-source";
import navigateTo from "@/services/navigateTo";
import { $route } from "@/services/ng";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import notification from "@/services/notification";
import PromiseRejectionError from "@/lib/promise-rejection-error";
import LoadingState from "@/components/items-list/components/LoadingState";
@@ -13,9 +12,11 @@ import DynamicForm from "@/components/dynamic-form/DynamicForm";
import helper from "@/components/dynamic-form/dynamicFormHelper";
import HelpTrigger, { TYPES as HELP_TRIGGER_TYPES } from "@/components/HelpTrigger";
import wrapSettingsTab from "@/components/SettingsWrapper";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
class EditDataSource extends React.Component {
static propTypes = {
dataSourceId: PropTypes.string.isRequired,
onError: PropTypes.func,
};
@@ -30,18 +31,14 @@ class EditDataSource extends React.Component {
};
componentDidMount() {
DataSource.get({ id: $route.current.params.dataSourceId })
DataSource.get({ id: this.props.dataSourceId })
.then(dataSource => {
const { type } = dataSource;
this.setState({ dataSource });
DataSource.types().then(types => this.setState({ type: find(types, { type }), loading: false }));
})
.catch(error => {
// ANGULAR_REMOVE_ME This code is related to Angular's HTTP services
if (error.status && error.data) {
error = new PromiseRejectionError(error);
}
this.props.onError(error);
this.props.onError(new PromiseRejectionError(error));
});
}
@@ -63,7 +60,7 @@ class EditDataSource extends React.Component {
DataSource.delete(dataSource)
.then(() => {
notification.success("Data source deleted successfully.");
navigateTo("/data_sources", true);
navigateTo("data_sources");
})
.catch(() => {
callback();
@@ -143,20 +140,16 @@ class EditDataSource extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component("pageEditDataSource", react2angular(wrapSettingsTab(null, EditDataSource)));
const EditDataSourcePage = wrapSettingsTab(null, EditDataSource);
return {
"/data_sources/:dataSourceId": {
template: '<page-edit-data-source on-error="handleError"></page-edit-data-source>',
title: "Data Sources",
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
},
};
}
init.init = true;
export default {
path: "/data_sources/:dataSourceId([0-9]+)",
title: "Data Sources",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <EditDataSourcePage {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
};

View File

@@ -1,19 +1,30 @@
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import { react2angular } from "react2angular";
import { isEmpty, get } from "lodash";
import Destination, { IMG_ROOT } from "@/services/destination";
import { policy } from "@/services/policy";
import navigateTo from "@/services/navigateTo";
import { $route } from "@/services/ng";
import { routesToAngularRoutes } from "@/lib/utils";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import CardsList from "@/components/cards-list/CardsList";
import LoadingState from "@/components/items-list/components/LoadingState";
import CreateSourceDialog from "@/components/CreateSourceDialog";
import helper from "@/components/dynamic-form/dynamicFormHelper";
import wrapSettingsTab from "@/components/SettingsWrapper";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import PromiseRejectionError from "@/lib/promise-rejection-error";
class DestinationsList extends React.Component {
static propTypes = {
isNewDestinationPage: PropTypes.bool,
onError: PropTypes.func,
};
static defaultProps = {
isNewDestinationPage: false,
onError: () => {},
};
state = {
destinationTypes: [],
destinations: [],
@@ -21,25 +32,27 @@ class DestinationsList extends React.Component {
};
componentDidMount() {
Promise.all([Destination.query(), Destination.types()]).then(values =>
this.setState(
{
destinations: values[0],
destinationTypes: values[1],
loading: false,
},
() => {
// all resources are loaded in state
if ($route.current.locals.isNewDestinationPage) {
if (policy.canCreateDestination()) {
this.showCreateSourceDialog();
} else {
navigateTo("/destinations");
Promise.all([Destination.query(), Destination.types()])
.then(values =>
this.setState(
{
destinations: values[0],
destinationTypes: values[1],
loading: false,
},
() => {
// all resources are loaded in state
if (this.props.isNewDestinationPage) {
if (policy.canCreateDestination()) {
this.showCreateSourceDialog();
} else {
navigateTo("destinations", true);
}
}
}
}
)
)
);
.catch(error => this.props.onError(new PromiseRejectionError(error)));
}
createDestination = (selectedType, values) => {
@@ -66,11 +79,15 @@ class DestinationsList extends React.Component {
sourceType: "Alert Destination",
imageFolder: IMG_ROOT,
onCreate: this.createDestination,
}).result.then((result = {}) => {
if (result.success) {
navigateTo(`destinations/${result.data.id}`);
}
});
})
.result.then((result = {}) => {
if (result.success) {
navigateTo(`destinations/${result.data.id}`);
}
})
.catch(() => {
navigateTo("destinations", true);
});
};
renderDestinations() {
@@ -119,45 +136,39 @@ class DestinationsList extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component(
"pageDestinationsList",
react2angular(
wrapSettingsTab(
{
permission: "admin",
title: "Alert Destinations",
path: "destinations",
order: 4,
},
DestinationsList
)
)
);
const DestinationsListPage = wrapSettingsTab(
{
permission: "admin",
title: "Alert Destinations",
path: "destinations",
order: 4,
},
DestinationsList
);
return routesToAngularRoutes(
[
{
path: "/destinations",
title: "Alert Destinations",
key: "destinations",
},
{
path: "/destinations/new",
title: "Alert Destinations",
key: "destinations",
isNewDestinationPage: true,
},
],
{
template: "<page-destinations-list></page-destinations-list>",
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
}
);
}
init.init = true;
export default [
{
path: "/destinations",
title: "Alert Destinations",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <DestinationsListPage {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/destinations/new",
title: "Alert Destinations",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<DestinationsListPage {...currentRoute.routeParams} isNewDestinationPage onError={handleError} />
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
];

View File

@@ -1,20 +1,21 @@
import React from "react";
import PropTypes from "prop-types";
import { get, find } from "lodash";
import { react2angular } from "react2angular";
import Modal from "antd/lib/modal";
import Destination, { IMG_ROOT } from "@/services/destination";
import navigateTo from "@/services/navigateTo";
import { $route } from "@/services/ng";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import notification from "@/services/notification";
import PromiseRejectionError from "@/lib/promise-rejection-error";
import LoadingState from "@/components/items-list/components/LoadingState";
import DynamicForm from "@/components/dynamic-form/DynamicForm";
import helper from "@/components/dynamic-form/dynamicFormHelper";
import wrapSettingsTab from "@/components/SettingsWrapper";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
class EditDestination extends React.Component {
static propTypes = {
destinationId: PropTypes.string.isRequired,
onError: PropTypes.func,
};
@@ -29,18 +30,14 @@ class EditDestination extends React.Component {
};
componentDidMount() {
Destination.get({ id: $route.current.params.destinationId })
Destination.get({ id: this.props.destinationId })
.then(destination => {
const { type } = destination;
this.setState({ destination });
Destination.types().then(types => this.setState({ type: find(types, { type }), loading: false }));
})
.catch(error => {
// ANGULAR_REMOVE_ME This code is related to Angular's HTTP services
if (error.status && error.data) {
error = new PromiseRejectionError(error);
}
this.props.onError(error);
this.props.onError(new PromiseRejectionError(error));
});
}
@@ -62,7 +59,7 @@ class EditDestination extends React.Component {
Destination.delete(destination)
.then(() => {
notification.success("Alert destination deleted successfully.");
navigateTo("/destinations", true);
navigateTo("destinations");
})
.catch(() => {
callback();
@@ -110,20 +107,16 @@ class EditDestination extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component("pageEditDestination", react2angular(wrapSettingsTab(null, EditDestination)));
const EditDestinationPage = wrapSettingsTab(null, EditDestination);
return {
"/destinations/:destinationId": {
template: '<page-edit-destination on-error="handleError"></page-edit-destination>',
title: "Alert Destinations",
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
},
};
}
init.init = true;
export default {
path: "/destinations/:destinationId([0-9]+)",
title: "Alert Destinations",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <EditDestinationPage {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
};

View File

@@ -1,11 +1,12 @@
import { filter, map, includes } from "lodash";
import React from "react";
import { react2angular } from "react2angular";
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 AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import Paginator from "@/components/Paginator";
import { wrap as liveItemsList, ControllerType } from "@/components/items-list/ItemsList";
@@ -22,13 +23,12 @@ import ListItemAddon from "@/components/groups/ListItemAddon";
import Sidebar from "@/components/groups/DetailsPageSidebar";
import Layout from "@/components/layouts/ContentWithSidebar";
import wrapSettingsTab from "@/components/SettingsWrapper";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import notification from "@/services/notification";
import { currentUser } from "@/services/auth";
import Group from "@/services/group";
import DataSource from "@/services/data-source";
import navigateTo from "@/services/navigateTo";
import { routesToAngularRoutes } from "@/lib/utils";
class GroupDataSources extends React.Component {
static propTypes = {
@@ -167,9 +167,11 @@ class GroupDataSources extends React.Component {
const promises = map(items, ds => Group.addDataSource({ id: this.groupId }, { data_source_id: ds.id }));
return Promise.all(promises);
},
}).result.finally(() => {
this.props.controller.update();
});
})
.result.catch(() => {}) // ignore dismiss
.finally(() => {
this.props.controller.update();
});
};
render() {
@@ -185,7 +187,7 @@ class GroupDataSources extends React.Component {
items={this.sidebarMenu}
canAddDataSources={currentUser.isAdmin}
onAddDataSourcesClick={this.addDataSources}
onGroupDeleted={() => navigateTo("/groups", true)}
onGroupDeleted={() => navigateTo("groups")}
/>
</Layout.Sidebar>
<Layout.Content>
@@ -227,47 +229,38 @@ class GroupDataSources extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component(
"pageGroupDataSources",
react2angular(
wrapSettingsTab(
null,
liveItemsList(
GroupDataSources,
new ResourceItemsSource({
isPlainList: true,
getRequest(unused, { params: { groupId } }) {
return { id: groupId };
},
getResource() {
return Group.dataSources.bind(Group);
},
}),
new StateStorage({ orderByField: "name" })
)
)
)
);
const GroupDataSourcesPage = wrapSettingsTab(
null,
liveItemsList(
GroupDataSources,
() =>
new ResourceItemsSource({
isPlainList: true,
getRequest(unused, { params: { groupId } }) {
return { id: groupId };
},
getResource() {
return Group.dataSources.bind(Group);
},
}),
() => new StateStorage({ orderByField: "name" })
)
);
return routesToAngularRoutes(
[
{
path: "/groups/:groupId/data_sources",
title: "Group Data Sources",
key: "datasources",
},
],
{
reloadOnSearch: false,
template: '<page-group-data-sources on-error="handleError"></page-group-data-sources>',
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
}
);
}
init.init = true;
export default {
path: "/groups/:groupId([0-9]+)/data_sources",
title: "Group Data Sources",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<GroupDataSourcesPage
routeParams={{ ...currentRoute.routeParams, currentPage: "datasources" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
};

View File

@@ -1,8 +1,9 @@
import { includes, map } from "lodash";
import React from "react";
import { react2angular } from "react2angular";
import Button from "antd/lib/button";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import Paginator from "@/components/Paginator";
import { wrap as liveItemsList, ControllerType } from "@/components/items-list/ItemsList";
@@ -19,13 +20,12 @@ import ListItemAddon from "@/components/groups/ListItemAddon";
import Sidebar from "@/components/groups/DetailsPageSidebar";
import Layout from "@/components/layouts/ContentWithSidebar";
import wrapSettingsTab from "@/components/SettingsWrapper";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import notification from "@/services/notification";
import { currentUser } from "@/services/auth";
import Group from "@/services/group";
import User from "@/services/user";
import navigateTo from "@/services/navigateTo";
import { routesToAngularRoutes } from "@/lib/utils";
class GroupMembers extends React.Component {
static propTypes = {
@@ -130,9 +130,11 @@ class GroupMembers extends React.Component {
const promises = map(items, u => Group.addMember({ id: this.groupId }, { user_id: u.id }));
return Promise.all(promises);
},
}).result.finally(() => {
this.props.controller.update();
});
})
.result.catch(() => {}) // ignore dismiss
.finally(() => {
this.props.controller.update();
});
};
render() {
@@ -148,7 +150,7 @@ class GroupMembers extends React.Component {
items={this.sidebarMenu}
canAddMembers={currentUser.isAdmin}
onAddMembersClick={this.addMembers}
onGroupDeleted={() => navigateTo("/groups", true)}
onGroupDeleted={() => navigateTo("groups")}
/>
</Layout.Sidebar>
<Layout.Content>
@@ -190,47 +192,38 @@ class GroupMembers extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component(
"pageGroupMembers",
react2angular(
wrapSettingsTab(
null,
liveItemsList(
GroupMembers,
new ResourceItemsSource({
isPlainList: true,
getRequest(unused, { params: { groupId } }) {
return { id: groupId };
},
getResource() {
return Group.members.bind(Group);
},
}),
new StateStorage({ orderByField: "name" })
)
)
)
);
const GroupMembersPage = wrapSettingsTab(
null,
liveItemsList(
GroupMembers,
() =>
new ResourceItemsSource({
isPlainList: true,
getRequest(unused, { params: { groupId } }) {
return { id: groupId };
},
getResource() {
return Group.members.bind(Group);
},
}),
() => new StateStorage({ orderByField: "name" })
)
);
return routesToAngularRoutes(
[
{
path: "/groups/:groupId",
title: "Group Members",
key: "users",
},
],
{
reloadOnSearch: false,
template: '<page-group-members on-error="handleError"></page-group-members>',
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
}
);
}
init.init = true;
export default {
path: "/groups/:groupId([0-9]+)",
title: "Group Members",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<GroupMembersPage
routeParams={{ ...currentRoute.routeParams, currentPage: "users" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
};

View File

@@ -1,7 +1,8 @@
import React from "react";
import { react2angular } from "react2angular";
import Button from "antd/lib/button";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import Paginator from "@/components/Paginator";
import { wrap as liveItemsList, ControllerType } from "@/components/items-list/ItemsList";
@@ -15,11 +16,10 @@ import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTab
import CreateGroupDialog from "@/components/groups/CreateGroupDialog";
import DeleteGroupButton from "@/components/groups/DeleteGroupButton";
import wrapSettingsTab from "@/components/SettingsWrapper";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import Group from "@/services/group";
import { currentUser } from "@/services/auth";
import navigateTo from "@/services/navigateTo";
import { routesToAngularRoutes } from "@/lib/utils";
class GroupsList extends React.Component {
static propTypes = {
@@ -74,9 +74,11 @@ class GroupsList extends React.Component {
];
createGroup = () => {
CreateGroupDialog.showModal().result.then(group => {
Group.create(group).then(newGroup => navigateTo(`/groups/${newGroup.id}`));
});
CreateGroupDialog.showModal()
.result.then(group => {
Group.create(group).then(newGroup => navigateTo(`groups/${newGroup.id}`));
})
.catch(() => {}); // ignore dismiss
};
onGroupDeleted = () => {
@@ -124,52 +126,43 @@ class GroupsList extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component(
"pageGroupsList",
react2angular(
wrapSettingsTab(
{
permission: "list_users",
title: "Groups",
path: "groups",
order: 3,
const GroupsListPage = wrapSettingsTab(
{
permission: "list_users",
title: "Groups",
path: "groups",
order: 3,
},
liveItemsList(
GroupsList,
() =>
new ResourceItemsSource({
isPlainList: true,
getRequest() {
return {};
},
liveItemsList(
GroupsList,
new ResourceItemsSource({
isPlainList: true,
getRequest() {
return {};
},
getResource() {
return Group.query.bind(Group);
},
}),
new StateStorage({ orderByField: "name", itemsPerPage: 10 })
)
)
)
);
getResource() {
return Group.query.bind(Group);
},
}),
() => new StateStorage({ orderByField: "name", itemsPerPage: 10 })
)
);
return routesToAngularRoutes(
[
{
path: "/groups",
title: "Groups",
key: "groups",
},
],
{
reloadOnSearch: false,
template: '<page-groups-list on-error="handleError"></page-groups-list>',
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
}
);
}
init.init = true;
export default {
path: "/groups",
title: "Groups",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<GroupsListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "groups" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
};

View File

@@ -2,9 +2,9 @@ import React, { useEffect, useState } from "react";
import { axios } from "@/services/axios";
import PropTypes from "prop-types";
import { includes, isEmpty } from "lodash";
import { react2angular } from "react2angular";
import Alert from "antd/lib/alert";
import Icon from "antd/lib/icon";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import EmptyState from "@/components/empty-state/EmptyState";
import DynamicComponent from "@/components/DynamicComponent";
import BeaconConsent from "@/components/BeaconConsent";
@@ -173,15 +173,12 @@ function Home() {
);
}
export default function init(ngModule) {
ngModule.component("homePage", react2angular(Home));
return {
"/": {
template: "<home-page></home-page>",
title: "Redash",
},
};
}
init.init = true;
export default {
path: "/",
title: "Redash",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<Home {...currentRoute.routeParams} />
</AuthenticatedPageWrapper>
),
};

53
client/app/pages/index.js Normal file
View File

@@ -0,0 +1,53 @@
import { flatten } from "lodash";
import adminJobsRoutes from "./admin/Jobs";
import adminOutdatedQueriesRoutes from "./admin/OutdatedQueries";
import adminSystemStatusRoutes from "./admin/SystemStatus";
import alertRoutes from "./alert/Alert";
import alertsListRoutes from "./alerts/AlertsList";
import dashboardListRoutes from "./dashboards/DashboardList";
import dashboardRoutes from "./dashboards/DashboardPage";
import publicDashboardRoutes from "./dashboards/PublicDashboardPage";
import dataSourcesListRoutes from "./data-sources/DataSourcesList";
import editDataSourceRoutes from "./data-sources/EditDataSource";
import destinationsListRoutes from "./destinations/DestinationsList";
import editDestinationRoutes from "./destinations/EditDestination";
import groupsListRoutes from "./groups/GroupsList";
import groupsDataSourcesRoutes from "./groups/GroupDataSources";
import groupsMembersRoutes from "./groups/GroupMembers";
import homeRoutes from "./home/Home";
import querySourceRoutes from "./queries/QuerySource";
import queryViewRoutes from "./queries/QueryView";
import visualizationEmbedRoutes from "./queries/VisualizationEmbed";
import queriesListRoutes from "./queries-list/QueriesList";
import querySnippetsRoutes from "./query-snippets/QuerySnippetsList";
import organizationSettingsRoutes from "./settings/OrganizationSettings";
import userProfileRoutes from "./users/UserProfile";
import usersListRoutes from "./users/UsersList";
export default flatten([
adminJobsRoutes,
adminOutdatedQueriesRoutes,
adminSystemStatusRoutes,
alertRoutes,
alertsListRoutes,
dashboardListRoutes,
dashboardRoutes,
publicDashboardRoutes,
dataSourcesListRoutes,
editDataSourceRoutes,
destinationsListRoutes,
editDestinationRoutes,
groupsListRoutes,
groupsDataSourcesRoutes,
groupsMembersRoutes,
homeRoutes,
queriesListRoutes,
queryViewRoutes,
querySourceRoutes,
visualizationEmbedRoutes,
querySnippetsRoutes,
organizationSettingsRoutes,
usersListRoutes,
userProfileRoutes,
]);

View File

@@ -1,6 +1,6 @@
import React from "react";
import { react2angular } from "react2angular";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import PageHeader from "@/components/PageHeader";
import Paginator from "@/components/Paginator";
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
@@ -15,10 +15,10 @@ import * as Sidebar from "@/components/items-list/components/Sidebar";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import Layout from "@/components/layouts/ContentWithSidebar";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import { Query } from "@/services/query";
import { currentUser } from "@/services/auth";
import { routesToAngularRoutes } from "@/lib/utils";
import QueriesListEmptyState from "./QueriesListEmptyState";
@@ -91,114 +91,145 @@ class QueriesList extends React.Component {
render() {
const { controller } = this.props;
return (
<div className="container">
<PageHeader title={controller.params.title} />
<Layout className="m-l-15 m-r-15">
<Layout.Sidebar className="m-b-0">
<Sidebar.SearchInput
placeholder="Search Queries..."
value={controller.searchTerm}
onChange={controller.updateSearch}
/>
<Sidebar.Menu items={this.sidebarMenu} selected={controller.params.currentPage} />
<Sidebar.Tags url="api/queries/tags" onChange={controller.updateSelectedTags} />
<Sidebar.PageSizeSelect
className="m-b-10"
options={controller.pageSizeOptions}
value={controller.itemsPerPage}
onChange={itemsPerPage => controller.updatePagination({ itemsPerPage })}
/>
</Layout.Sidebar>
<Layout.Content>
{!controller.isLoaded && <LoadingState />}
{controller.isLoaded && controller.isEmpty && (
<QueriesListEmptyState
page={controller.params.currentPage}
searchTerm={controller.searchTerm}
selectedTags={controller.selectedTags}
<div className="page-queries-list">
<div className="container">
<PageHeader title={controller.params.title} />
<Layout className="m-l-15 m-r-15">
<Layout.Sidebar className="m-b-0">
<Sidebar.SearchInput
placeholder="Search Queries..."
value={controller.searchTerm}
onChange={controller.updateSearch}
/>
)}
{controller.isLoaded && !controller.isEmpty && (
<div className="bg-white tiled table-responsive">
<ItemsTable
items={controller.pageItems}
columns={this.listColumns}
orderByField={controller.orderByField}
orderByReverse={controller.orderByReverse}
toggleSorting={controller.toggleSorting}
<Sidebar.Menu items={this.sidebarMenu} selected={controller.params.currentPage} />
<Sidebar.Tags url="api/queries/tags" onChange={controller.updateSelectedTags} />
<Sidebar.PageSizeSelect
className="m-b-10"
options={controller.pageSizeOptions}
value={controller.itemsPerPage}
onChange={itemsPerPage => controller.updatePagination({ itemsPerPage })}
/>
</Layout.Sidebar>
<Layout.Content>
{!controller.isLoaded && <LoadingState />}
{controller.isLoaded && controller.isEmpty && (
<QueriesListEmptyState
page={controller.params.currentPage}
searchTerm={controller.searchTerm}
selectedTags={controller.selectedTags}
/>
<Paginator
totalCount={controller.totalItemsCount}
itemsPerPage={controller.itemsPerPage}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
</div>
)}
</Layout.Content>
</Layout>
)}
{controller.isLoaded && !controller.isEmpty && (
<div className="bg-white tiled table-responsive">
<ItemsTable
items={controller.pageItems}
columns={this.listColumns}
orderByField={controller.orderByField}
orderByReverse={controller.orderByReverse}
toggleSorting={controller.toggleSorting}
/>
<Paginator
totalCount={controller.totalItemsCount}
itemsPerPage={controller.itemsPerPage}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
</div>
)}
</Layout.Content>
</Layout>
</div>
</div>
);
}
}
export default function init(ngModule) {
ngModule.component(
"pageQueriesList",
react2angular(
itemsList(
QueriesList,
new ResourceItemsSource({
getResource({ params: { currentPage } }) {
return {
all: Query.query.bind(Query),
my: Query.myQueries.bind(Query),
favorites: Query.favorites.bind(Query),
archive: Query.archive.bind(Query),
}[currentPage];
},
getItemProcessor() {
return item => new Query(item);
},
}),
new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
)
)
);
const QueriesListPage = itemsList(
QueriesList,
() =>
new ResourceItemsSource({
getResource({ params: { currentPage } }) {
return {
all: Query.query.bind(Query),
my: Query.myQueries.bind(Query),
favorites: Query.favorites.bind(Query),
archive: Query.archive.bind(Query),
}[currentPage];
},
getItemProcessor() {
return item => new Query(item);
},
}),
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
);
return routesToAngularRoutes(
[
{
path: "/queries",
title: "Queries",
key: "all",
},
{
path: "/queries/favorites",
title: "Favorite Queries",
key: "favorites",
},
{
path: "/queries/archive",
title: "Archived Queries",
key: "archive",
},
{
path: "/queries/my",
title: "My Queries",
key: "my",
},
],
{
reloadOnSearch: false,
template: '<page-queries-list on-error="handleError"></page-queries-list>',
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
}
);
}
init.init = true;
export default [
{
path: "/queries",
title: "Queries",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<QueriesListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "all" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/queries/favorites",
title: "Favorite Queries",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<QueriesListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "favorites" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/queries/archive",
title: "Archived Queries",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<QueriesListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "archive" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/queries/my",
title: "My Queries",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<QueriesListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "my" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
];

View File

@@ -1,9 +1,9 @@
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 AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import Resizable from "@/components/Resizable";
import Parameters from "@/components/Parameters";
import EditInPlace from "@/components/EditInPlace";
@@ -11,7 +11,7 @@ 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 { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import { durationHumanize, prettySize } from "@/lib/utils";
import { Query } from "@/services/query";
import recordEvent from "@/services/recordEvent";
@@ -443,45 +443,31 @@ 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: '<page-query-source ng-if="$resolve.query" query="$resolve.query"></page-query-source>',
resolve: {
query: () => Query.newQuery(),
},
}
export default [
{
path: "/queries/new",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key} bodyClass="fixed-layout">
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <QuerySource {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
...routesToAngularRoutes(
[
{
path: "/queries/:queryId/source",
},
],
{
layout: "fixed",
reloadOnSearch: false,
template: '<page-query-source ng-if="$resolve.query" query="$resolve.query"></page-query-source>',
resolve: {
query: $route => {
"ngInject";
return Query.get({ id: $route.current.params.queryId });
},
},
}
resolve: {
query: () => Query.newQuery(),
},
},
{
path: "/queries/:queryId([0-9]+)/source",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key} bodyClass="fixed-layout">
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <QuerySource {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
};
}
init.init = true;
resolve: {
query: ({ queryId }) => Query.get({ id: queryId }),
},
},
];

View File

@@ -1,8 +1,3 @@
page-query-source {
display: flex;
flex-grow: 1;
}
.query-fullscreen {
.query-editor-wrapper {
padding: 15px;

View File

@@ -1,16 +1,17 @@
import React, { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import { react2angular } from "react2angular";
import Divider from "antd/lib/divider";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
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 { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import DataSource from "@/services/data-source";
import { Query } from "@/services/query";
import DataSource from "@/services/data-source";
import { pluralize, durationHumanize } from "@/lib/utils";
import QueryPageHeader from "./components/QueryPageHeader";
@@ -184,22 +185,16 @@ function QueryView(props) {
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: '<page-query-view query="$resolve.query"></page-query-view>',
reloadOnSearch: false,
resolve: {
query: $route => {
"ngInject";
return Query.get({ id: $route.current.params.queryId });
},
},
},
};
}
init.init = true;
export default {
path: "/queries/:queryId([0-9]+)",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <QueryView {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
resolve: {
query: ({ queryId }) => Query.get({ id: queryId }),
},
};

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import PropTypes from "prop-types";
import { find, has } from "lodash";
import { react2angular } from "react2angular";
import moment from "moment";
import { markdown } from "markdown";
import Button from "antd/lib/button";
@@ -9,7 +8,9 @@ import Dropdown from "antd/lib/dropdown";
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 SignedOutPageWrapper from "@/components/ApplicationArea/SignedOutPageWrapper";
import { Query } from "@/services/query";
import location from "@/services/location";
import { formatDateTime } from "@/lib/utils";
import HtmlContent from "@/components/HtmlContent";
import Parameters from "@/components/Parameters";
@@ -17,12 +18,13 @@ import { Moment } from "@/components/proptypes";
import TimeAgo from "@/components/TimeAgo";
import Timer from "@/components/Timer";
import QueryResultsLink from "@/components/EditVisualizationButton/QueryResultsLink";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import VisualizationName from "@/visualizations/VisualizationName";
import VisualizationRenderer from "@/visualizations/VisualizationRenderer";
import { VisualizationType } from "@/visualizations";
import { Query } from "@/services/query";
import logoUrl from "@/assets/images/redash_icon_small.png";
import PromiseRejectionError from "@/lib/promise-rejection-error";
function VisualizationEmbedHeader({ queryName, queryDescription, visualization }) {
return (
@@ -48,7 +50,15 @@ VisualizationEmbedHeader.propTypes = {
VisualizationEmbedHeader.defaultProps = { queryDescription: "" };
function VisualizationEmbedFooter({ query, queryResults, updatedAt, refreshStartedAt, queryUrl, hideTimestamp }) {
function VisualizationEmbedFooter({
query,
queryResults,
updatedAt,
refreshStartedAt,
queryUrl,
hideTimestamp,
apiKey,
}) {
const downloadMenu = (
<Menu>
<Menu.Item>
@@ -56,7 +66,7 @@ function VisualizationEmbedFooter({ query, queryResults, updatedAt, refreshStart
fileType="csv"
query={query}
queryResult={queryResults}
apiKey={$routeParams.api_key}
apiKey={apiKey}
disabled={!queryResults || !queryResults.getData || !queryResults.getData()}
embed>
<Icon type="file" /> Download as CSV File
@@ -67,7 +77,7 @@ function VisualizationEmbedFooter({ query, queryResults, updatedAt, refreshStart
fileType="tsv"
query={query}
queryResult={queryResults}
apiKey={$routeParams.api_key}
apiKey={apiKey}
disabled={!queryResults || !queryResults.getData || !queryResults.getData()}
embed>
<Icon type="file" /> Download as TSV File
@@ -78,7 +88,7 @@ function VisualizationEmbedFooter({ query, queryResults, updatedAt, refreshStart
fileType="xlsx"
query={query}
queryResult={queryResults}
apiKey={$routeParams.api_key}
apiKey={apiKey}
disabled={!queryResults || !queryResults.getData || !queryResults.getData()}
embed>
<Icon type="file-excel" /> Download as Excel File
@@ -128,6 +138,7 @@ VisualizationEmbedFooter.propTypes = {
refreshStartedAt: Moment,
queryUrl: PropTypes.string,
hideTimestamp: PropTypes.bool,
apiKey: PropTypes.string,
};
VisualizationEmbedFooter.defaultProps = {
@@ -136,33 +147,47 @@ VisualizationEmbedFooter.defaultProps = {
refreshStartedAt: null,
queryUrl: null,
hideTimestamp: false,
apiKey: null,
};
function VisualizationEmbed({ query }) {
function VisualizationEmbed({ queryId, visualizationId, apiKey, onError }) {
const [query, setQuery] = useState(null);
const [error, setError] = useState(null);
const [refreshStartedAt, setRefreshStartedAt] = useState(null);
const [queryResults, setQueryResults] = useState(null);
const hideHeader = has($location.search(), "hide_header");
const hideParametersUI = has($location.search(), "hide_parameters");
const hideQueryLink = has($location.search(), "hide_link");
const hideTimestamp = has($location.search(), "hide_timestamp");
const showQueryDescription = has($location.search(), "showDescription");
const visualizationId = parseInt($routeParams.visualizationId, 10);
const visualization = find(query.visualizations, vis => vis.id === visualizationId);
const onErrorRef = useRef();
onErrorRef.current = onError;
useEffect(() => {
let isCancelled = false;
Query.get({ id: queryId })
.then(result => {
if (!isCancelled) {
setQuery(result);
}
})
.catch(error => onErrorRef.current(new PromiseRejectionError(error)));
return () => {
isCancelled = true;
};
}, [queryId]);
const refreshQueryResults = useCallback(() => {
setError(null);
setRefreshStartedAt(moment());
query
.getQueryResultPromise()
.then(result => {
setQueryResults(result);
})
.catch(err => {
setError(err.getError());
})
.finally(() => setRefreshStartedAt(null));
if (query) {
setError(null);
setRefreshStartedAt(moment());
query
.getQueryResultPromise()
.then(result => {
setQueryResults(result);
})
.catch(err => {
setError(err.getError());
})
.finally(() => setRefreshStartedAt(null));
}
}, [query]);
useEffect(() => {
@@ -170,6 +195,27 @@ function VisualizationEmbed({ query }) {
refreshQueryResults();
}, [refreshQueryResults]);
if (!query) {
return null;
}
const hideHeader = has(location.search, "hide_header");
const hideParametersUI = has(location.search, "hide_parameters");
const hideQueryLink = has(location.search, "hide_link");
const hideTimestamp = has(location.search, "hide_timestamp");
const showQueryDescription = has(location.search, "showDescription");
visualizationId = parseInt(visualizationId, 10);
const visualization = find(query.visualizations, vis => vis.id === visualizationId);
if (!visualization) {
// call error handler async, otherwise it will destroy the component on render phase
setTimeout(() => {
onError(new Error("Visualization does not exist"));
}, 10);
return null;
}
return (
<div className="tile m-l-10 m-r-10 p-t-10 embed__vis" data-test="VisualizationEmbed">
{!hideHeader && (
@@ -204,38 +250,33 @@ function VisualizationEmbed({ query }) {
refreshStartedAt={refreshStartedAt}
queryUrl={!hideQueryLink ? query.getUrl() : null}
hideTimestamp={hideTimestamp}
apiKey={apiKey}
/>
</div>
);
}
VisualizationEmbed.propTypes = { query: PropTypes.object.isRequired }; // eslint-disable-line react/forbid-prop-types
VisualizationEmbed.propTypes = {
queryId: PropTypes.string.isRequired,
visualizationId: PropTypes.string,
apiKey: PropTypes.string.isRequired,
onError: PropTypes.func,
};
export default function init(ngModule) {
ngModule.component("visualizationEmbed", react2angular(VisualizationEmbed));
VisualizationEmbed.defaultProps = {
onError: () => {},
};
function loadSession($route, Auth) {
const apiKey = $route.current.params.api_key;
Auth.setApiKey(apiKey);
return Auth.loadConfig();
}
function loadQuery($route, Auth) {
"ngInject";
return loadSession($route, Auth).then(() => Query.get({ id: $route.current.params.queryId }));
}
return {
"/embed/query/:queryId/visualization/:visualizationId": {
authenticated: false,
template: '<visualization-embed query="$resolve.query"></visualization-embed>',
reloadOnSearch: false,
resolve: {
query: loadQuery,
},
},
};
}
init.init = true;
export default {
path: "/embed/query/:queryId/visualization/:visualizationId",
authenticated: false,
render: currentRoute => (
<SignedOutPageWrapper key={currentRoute.key} apiKey={location.search.api_key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<VisualizationEmbed {...currentRoute.routeParams} apiKey={location.search.api_key} onError={handleError} />
)}
</ErrorBoundaryContext.Consumer>
</SignedOutPageWrapper>
),
};

View File

@@ -15,10 +15,12 @@ export default function useAddNewParameterDialog(query, onParameterAdded) {
value: null,
},
existingParams: map(query.getParameters().get(), p => p.name),
}).result.then(param => {
const newQuery = query.clone();
param = newQuery.getParameters().add(param);
onParameterAddedRef.current(newQuery, param);
});
})
.result.then(param => {
const newQuery = query.clone();
param = newQuery.getParameters().add(param);
onParameterAddedRef.current(newQuery, param);
})
.catch(() => {}); // ignore dismiss
}, [query]);
}

View File

@@ -6,7 +6,7 @@ export default function useAddToDashboardDialog(query) {
return useCallback(
visualizationId => {
const visualization = find(query.visualizations, { id: visualizationId });
AddToDashboardDialog.showModal({ visualization });
AddToDashboardDialog.showModal({ visualization }).result.catch(() => {}); // ignore dismiss
},
[query.visualizations]
);

View File

@@ -7,8 +7,10 @@ export default function useApiKeyDialog(query, onChange) {
onChangeRef.current = isFunction(onChange) ? onChange : () => {};
return useCallback(() => {
ApiKeyDialog.showModal({ query }).result.then(updatedQuery => {
onChangeRef.current(updatedQuery);
});
ApiKeyDialog.showModal({ query })
.result.then(updatedQuery => {
onChangeRef.current(updatedQuery);
})
.catch(() => {}); // ignore dismiss
}, [query]);
}

View File

@@ -25,9 +25,11 @@ export default function useEditScheduleDialog(query, onChange) {
ScheduleDialog.showModal({
schedule: query.schedule,
refreshOptions,
}).result.then(schedule => {
recordEvent("edit_schedule", "query", query.id);
updateQuery({ schedule });
});
})
.result.then(schedule => {
recordEvent("edit_schedule", "query", query.id);
updateQuery({ schedule });
})
.catch(() => {}); // ignore dismiss
}, [query.id, query.schedule, queryFlags.canEdit, queryFlags.canSchedule, updateQuery]);
}

View File

@@ -13,13 +13,15 @@ export default function useEditVisualizationDialog(query, queryResult, onChange)
query,
visualization,
queryResult,
}).result.then(updatedVisualization => {
const filteredVisualizations = filter(query.visualizations, v => v.id !== updatedVisualization.id);
onChangeRef.current(
extend(query.clone(), { visualizations: [...filteredVisualizations, updatedVisualization] }),
updatedVisualization
);
});
})
.result.then(updatedVisualization => {
const filteredVisualizations = filter(query.visualizations, v => v.id !== updatedVisualization.id);
onChangeRef.current(
extend(query.clone(), { visualizations: [...filteredVisualizations, updatedVisualization] }),
updatedVisualization
);
})
.catch(() => {}); // ignore dismiss
},
[query, queryResult]
);

View File

@@ -6,7 +6,7 @@ export default function useEmbedDialog(query) {
return useCallback(
(unusedQuery, visualizationId) => {
const visualization = find(query.visualizations, { id: visualizationId });
EmbedQueryDialog.showModal({ query, visualization });
EmbedQueryDialog.showModal({ query, visualization }).result.catch(() => {}); // ignore dismiss
},
[query]
);

View File

@@ -7,6 +7,6 @@ export default function usePermissionsEditorDialog(query) {
aclUrl: `api/queries/${query.id}/acl`,
context: "query",
author: query.user,
});
}).result.catch(() => {}); // ignore dismiss
}, [query.id, query.user]);
}

View File

@@ -1,6 +1,6 @@
import { useState, useMemo } from "react";
import useUpdateQuery from "./useUpdateQuery";
import navigateTo from "@/services/navigateTo";
import navigateTo from "@/components/ApplicationArea/navigateTo";
export default function useQuery(originalQuery) {
const [query, setQuery] = useState(originalQuery);
@@ -10,7 +10,7 @@ export default function useQuery(originalQuery) {
// It's important to update URL first, and only then update state
if (updatedQuery.id !== query.id) {
// Don't reload page when saving new query
navigateTo(updatedQuery.getSourceLink(), true, false);
navigateTo(updatedQuery.getUrl(true), true);
}
setQuery(updatedQuery);
setOriginalQuerySource(updatedQuery.query);

View File

@@ -1,11 +1,11 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { noop, includes } from "lodash";
import useQueryResult from "@/lib/hooks/useQueryResult";
import { $location } from "@/services/ng";
import location from "@/services/location";
import recordEvent from "@/services/recordEvent";
function getMaxAge() {
const maxAge = $location.search().maxAge;
const { maxAge } = location.search;
return maxAge !== undefined ? maxAge : -1;
}

View File

@@ -1,5 +1,5 @@
import { useRef, useEffect } from "react";
import { $rootScope } from "@/services/ng";
import location from "@/services/location";
// TODO: This should be revisited and probably re-implemented when replacing Angular router with sth else
export default function useUnsavedChangesAlert(shouldShowAlert = false) {
@@ -16,13 +16,9 @@ export default function useUnsavedChangesAlert(shouldShowAlert = false) {
return shouldShowAlertRef.current ? unloadMessage : undefined;
};
const unsubscribe = $rootScope.$on("$locationChangeStart", (event, next, current) => {
if (next.split("?")[0] === current.split("?")[0] || next.split("#")[0] === current.split("#")[0]) {
return;
}
if (shouldShowAlertRef.current && !window.confirm(confirmMessage)) {
event.preventDefault();
const unsubscribe = location.confirmChange((nextLocation, currentLocation) => {
if (shouldShowAlertRef.current && nextLocation.path !== currentLocation.path) {
return confirmMessage;
}
});

View File

@@ -1,31 +1,23 @@
import { useState, useEffect, useMemo } from "react";
import { first, orderBy, find } from "lodash";
import { $location, $rootScope } from "@/services/ng";
function updateUrlHash(...args) {
$location.hash(...args);
$rootScope.$applyAsync();
}
import location from "@/services/location";
export default function useVisualizationTabHandler(visualizations) {
const firstVisualization = useMemo(() => first(orderBy(visualizations, ["id"])) || {}, [visualizations]);
const [selectedTab, setSelectedTab] = useState(+$location.hash() || firstVisualization.id);
const [selectedTab, setSelectedTab] = useState(+location.hash || firstVisualization.id);
useEffect(() => {
const hashValue = selectedTab !== firstVisualization.id ? `${selectedTab}` : null;
if ($location.hash() !== hashValue) {
updateUrlHash(hashValue);
if (location.hash !== hashValue) {
location.setHash(hashValue);
}
const unwatch = $rootScope.$watch(
() => $location.hash(),
() => {
if ($location.hash() !== hashValue) {
setSelectedTab(+$location.hash() || firstVisualization.id);
}
const unlisten = location.listen(() => {
if (location.hash !== hashValue) {
setSelectedTab(+location.hash || firstVisualization.id);
}
);
return unwatch;
});
return unlisten;
}, [firstVisualization.id, selectedTab]);
// make sure selectedTab is in visualizations

View File

@@ -1,10 +1,11 @@
import { get } from "lodash";
import React from "react";
import { react2angular } from "react2angular";
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
import PromiseRejectionError from "@/lib/promise-rejection-error";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import Paginator from "@/components/Paginator";
import QuerySnippetDialog from "@/components/query-snippets/QuerySnippetDialog";
@@ -15,13 +16,12 @@ import { StateStorage } from "@/components/items-list/classes/StateStorage";
import LoadingState from "@/components/items-list/components/LoadingState";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import wrapSettingsTab from "@/components/SettingsWrapper";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import QuerySnippet from "@/services/query-snippet";
import navigateTo from "@/services/navigateTo";
import { currentUser } from "@/services/auth";
import { policy } from "@/services/policy";
import notification from "@/services/notification";
import { routesToAngularRoutes } from "@/lib/utils";
import "./QuerySnippetsList.less";
const canEditQuerySnippet = querySnippet => currentUser.isAdmin || currentUser.id === get(querySnippet, "user.id");
@@ -83,17 +83,13 @@ class QuerySnippetsList extends React.Component {
if (policy.isCreateQuerySnippetEnabled()) {
this.showSnippetDialog();
} else {
navigateTo("/query_snippets");
navigateTo("query_snippets", true);
}
} else {
QuerySnippet.get({ id: querySnippetId })
.then(this.showSnippetDialog)
.catch((error = {}) => {
// ANGULAR_REMOVE_ME This code is related to Angular's HTTP services
if (error.status && error.data) {
error = new PromiseRejectionError(error);
}
this.props.controller.handleError(error);
this.props.controller.handleError(new PromiseRejectionError(error));
});
}
}
@@ -126,15 +122,16 @@ class QuerySnippetsList extends React.Component {
showSnippetDialog = (querySnippet = null) => {
const canSave = !querySnippet || canEditQuerySnippet(querySnippet);
navigateTo("/query_snippets/" + get(querySnippet, "id", "new"), true, false);
navigateTo("query_snippets/" + get(querySnippet, "id", "new"), true);
QuerySnippetDialog.showModal({
querySnippet,
onSubmit: this.saveQuerySnippet,
readOnly: !canSave,
})
.result.then(() => this.props.controller.update())
.catch(() => {}) // ignore dismiss
.finally(() => {
navigateTo("/query_snippets", true, false);
navigateTo("query_snippets", true);
});
};
@@ -190,58 +187,62 @@ class QuerySnippetsList extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component(
"pageQuerySnippetsList",
react2angular(
wrapSettingsTab(
{
permission: "create_query",
title: "Query Snippets",
path: "query_snippets",
order: 5,
const QuerySnippetsListPage = wrapSettingsTab(
{
permission: "create_query",
title: "Query Snippets",
path: "query_snippets",
order: 5,
},
liveItemsList(
QuerySnippetsList,
() =>
new ResourceItemsSource({
isPlainList: true,
getRequest() {
return {};
},
liveItemsList(
QuerySnippetsList,
new ResourceItemsSource({
isPlainList: true,
getRequest() {
return {};
},
getResource() {
return QuerySnippet.query.bind(QuerySnippet);
},
}),
new StateStorage({ orderByField: "trigger", itemsPerPage: 10 })
)
)
)
);
getResource() {
return QuerySnippet.query.bind(QuerySnippet);
},
}),
() => new StateStorage({ orderByField: "trigger", itemsPerPage: 10 })
)
);
return routesToAngularRoutes(
[
{
path: "/query_snippets",
title: "Query Snippets",
key: "query_snippets",
},
{
path: "/query_snippets/:querySnippetId",
title: "Query Snippets",
key: "query_snippets",
isNewOrEditPage: true,
},
],
{
reloadOnSearch: false,
template: '<page-query-snippets-list on-error="handleError"></page-query-snippets-list>',
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
}
);
}
init.init = true;
export default [
{
path: "/query_snippets",
title: "Query Snippets",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<QuerySnippetsListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "query_snippets" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/query_snippets/:querySnippetId(new|[0-9]+)",
title: "Query Snippets",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<QuerySnippetsListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "query_snippets", isNewOrEditPage: true }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
];

View File

@@ -1,5 +1,5 @@
import React from "react";
import { react2angular } from "react2angular";
import PropTypes from "prop-types";
import { isEmpty, join, get } from "lodash";
import Alert from "antd/lib/alert";
@@ -9,19 +9,29 @@ import Input from "antd/lib/input";
import Select from "antd/lib/select";
import Checkbox from "antd/lib/checkbox";
import Tooltip from "antd/lib/tooltip";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import LoadingState from "@/components/items-list/components/LoadingState";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import { routesToAngularRoutes } from "@/lib/utils";
import { clientConfig } from "@/services/auth";
import recordEvent from "@/services/recordEvent";
import OrgSettings from "@/services/organizationSettings";
import HelpTrigger from "@/components/HelpTrigger";
import wrapSettingsTab from "@/components/SettingsWrapper";
import DynamicComponent from "@/components/DynamicComponent";
import PromiseRejectionError from "@/lib/promise-rejection-error";
const Option = Select.Option;
class OrganizationSettings extends React.Component {
static propTypes = {
onError: PropTypes.func,
};
static defaultProps = {
onError: () => {},
};
state = {
settings: {},
formValues: {},
@@ -31,10 +41,12 @@ class OrganizationSettings extends React.Component {
componentDidMount() {
recordEvent("view", "page", "org_settings");
OrgSettings.get().then(response => {
const settings = get(response, "settings");
this.setState({ settings, formValues: { ...settings }, loading: false });
});
OrgSettings.get()
.then(response => {
const settings = get(response, "settings");
this.setState({ settings, formValues: { ...settings }, loading: false });
})
.catch(error => this.props.onError(new PromiseRejectionError(error)));
}
disablePasswordLoginToggle = () => !(clientConfig.googleLoginEnabled || this.state.formValues.auth_saml_enabled);
@@ -48,6 +60,7 @@ class OrganizationSettings extends React.Component {
const settings = get(response, "settings");
this.setState({ settings, formValues: { ...settings } });
})
.catch(error => this.props.onError(new PromiseRejectionError(error)))
.finally(() => this.setState({ submitting: false }));
}
};
@@ -259,40 +272,24 @@ class OrganizationSettings extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component(
"pageOrganizationSettings",
react2angular(
wrapSettingsTab(
{
permission: "admin",
title: "Settings",
path: "settings/organization",
order: 6,
},
OrganizationSettings
)
)
);
const OrganizationSettingsPage = wrapSettingsTab(
{
permission: "admin",
title: "Settings",
path: "settings/organization",
order: 6,
},
OrganizationSettings
);
return routesToAngularRoutes(
[
{
path: "/settings/organization",
title: "Organization Settings",
key: "organization-settings",
},
],
{
reloadOnSearch: false,
template: '<page-organization-settings on-error="handleError"></page-organization-settings>',
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
}
);
}
init.init = true;
export default {
path: "/settings/organization",
title: "Organization Settings",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <OrganizationSettingsPage {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
};

View File

@@ -1,25 +1,27 @@
import React from "react";
import PropTypes from "prop-types";
import { react2angular } from "react2angular";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import EmailSettingsWarning from "@/components/EmailSettingsWarning";
import UserEdit from "@/components/users/UserEdit";
import UserShow from "@/components/users/UserShow";
import LoadingState from "@/components/items-list/components/LoadingState";
import wrapSettingsTab from "@/components/SettingsWrapper";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import User from "@/services/user";
import { $route } from "@/services/ng";
import { currentUser } from "@/services/auth";
import PromiseRejectionError from "@/lib/promise-rejection-error";
import "./settings.less";
class UserProfile extends React.Component {
static propTypes = {
userId: PropTypes.string,
onError: PropTypes.func,
};
static defaultProps = {
userId: null, // defaults to `currentUser.id`
onError: () => {},
};
@@ -29,15 +31,11 @@ class UserProfile extends React.Component {
}
componentDidMount() {
const userId = $route.current.params.userId || currentUser.id;
const userId = this.props.userId || currentUser.id;
User.get({ id: userId })
.then(user => this.setState({ user: User.convertUserInfo(user) }))
.catch(error => {
// ANGULAR_REMOVE_ME This code is related to Angular's HTTP services
if (error.status && error.data) {
error = new PromiseRejectionError(error);
}
this.props.onError(error);
this.props.onError(new PromiseRejectionError(error));
});
}
@@ -54,20 +52,36 @@ class UserProfile extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component(
"pageUserProfile",
react2angular(
wrapSettingsTab(
{
title: "Account",
path: "users/me",
order: 7,
},
UserProfile
)
)
);
}
const UserProfilePage = wrapSettingsTab(
{
title: "Account",
path: "users/me",
order: 7,
},
UserProfile
);
init.init = true;
export default [
{
path: "/users/me",
title: "Account",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <UserProfilePage {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/users/:userId([0-9]+)",
title: "Users",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => <UserProfilePage {...currentRoute.routeParams} onError={handleError} />}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
];

View File

@@ -1,10 +1,10 @@
import { map, get } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import { react2angular } from "react2angular";
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
import AuthenticatedPageWrapper from "@/components/ApplicationArea/AuthenticatedPageWrapper";
import Paginator from "@/components/Paginator";
import DynamicComponent from "@/components/DynamicComponent";
import { UserPreviewCard } from "@/components/PreviewCard";
@@ -22,11 +22,12 @@ import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTab
import Layout from "@/components/layouts/ContentWithSidebar";
import CreateUserDialog from "@/components/users/CreateUserDialog";
import wrapSettingsTab from "@/components/SettingsWrapper";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import { currentUser } from "@/services/auth";
import { policy } from "@/services/policy";
import User from "@/services/user";
import navigateTo from "@/services/navigateTo";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import notification from "@/services/notification";
import { absoluteUrl } from "@/services/utils";
@@ -168,6 +169,7 @@ class UsersList extends React.Component {
if (policy.isCreateUserEnabled()) {
CreateUserDialog.showModal({ onCreate: this.createUser })
.result.then(() => this.props.controller.update())
.catch(() => {}) // ignore dismiss
.finally(() => {
if (this.props.controller.params.isNewUserPage) {
navigateTo("users");
@@ -242,45 +244,108 @@ class UsersList extends React.Component {
}
}
export default function init(ngModule) {
ngModule.component(
"pageUsersList",
react2angular(
wrapSettingsTab(
{
permission: "list_users",
title: "Users",
path: "users",
isActive: path => path.startsWith("/users") && path !== "/users/me",
order: 2,
const UsersListPage = wrapSettingsTab(
{
permission: "list_users",
title: "Users",
path: "users",
isActive: path => path.startsWith("/users") && path !== "/users/me",
order: 2,
},
itemsList(
UsersList,
() =>
new ResourceItemsSource({
getRequest(request, { params: { currentPage } }) {
switch (currentPage) {
case "active":
request.pending = false;
break;
case "pending":
request.pending = true;
break;
case "disabled":
request.disabled = true;
break;
// no default
}
return request;
},
itemsList(
UsersList,
new ResourceItemsSource({
getRequest(request, { params: { currentPage } }) {
switch (currentPage) {
case "active":
request.pending = false;
break;
case "pending":
request.pending = true;
break;
case "disabled":
request.disabled = true;
break;
// no default
}
return request;
},
getResource() {
return User.query.bind(User);
},
}),
new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
)
)
)
);
}
getResource() {
return User.query.bind(User);
},
}),
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
)
);
init.init = true;
export default [
{
path: "/users",
title: "Users",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<UsersListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "active" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/users/new",
title: "Users",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<UsersListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "active", isNewUserPage: true }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/users/pending",
title: "Pending Invitations",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<UsersListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "pending" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
{
path: "/users/disabled",
title: "Disabled Users",
render: currentRoute => (
<AuthenticatedPageWrapper key={currentRoute.key}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) => (
<UsersListPage
routeParams={{ ...currentRoute.routeParams, currentPage: "disabled" }}
currentRoute={currentRoute}
onError={handleError}
/>
)}
</ErrorBoundaryContext.Consumer>
</AuthenticatedPageWrapper>
),
},
];

View File

@@ -1,67 +0,0 @@
import { extend } from "lodash";
import { routesToAngularRoutes } from "@/lib/utils";
export default function init() {
const listRoutes = routesToAngularRoutes(
[
{
path: "/users",
title: "Users",
key: "active",
},
{
path: "/users/new",
title: "Users",
key: "active",
isNewUserPage: true,
},
{
path: "/users/pending",
title: "Pending Invitations",
key: "pending",
},
{
path: "/users/disabled",
title: "Disabled Users",
key: "disabled",
},
],
{
template: '<page-users-list on-error="handleError"></page-users-list>',
reloadOnSearch: false,
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
}
);
const profileRoutes = routesToAngularRoutes(
[
{
path: "/users/me",
title: "Account",
key: "users",
},
{
path: "/users/:userId",
title: "Users",
key: "users",
},
],
{
reloadOnSearch: false,
template: '<page-user-profile on-error="handleError"></page-user-profile>',
controller($scope, $exceptionHandler) {
"ngInject";
$scope.handleError = $exceptionHandler;
},
}
);
return extend(listRoutes, profileRoutes);
}
init.init = true;

View File

@@ -1,10 +1,8 @@
import debug from "debug";
import { includes, extend } from "lodash";
import location from "@/services/location";
import { axios } from "@/services/axios";
// eslint-disable-next-line import/no-mutable-exports
export let Auth = null;
export const currentUser = {
canEdit(object) {
const userId = object.user_id || (object.user && object.user.id);
@@ -40,78 +38,63 @@ function updateSession(sessionData) {
extend(messages, session.messages);
}
function AuthService($window, $location, $q) {
return {
isAuthenticated() {
return session.loaded && session.user.id;
},
login() {
const next = encodeURI($location.url());
logger("Calling login with next = %s", next);
window.location.href = `login?next=${next}`;
},
logout() {
logger("Logout.");
$window.location.href = "logout";
},
loadSession() {
logger("Loading session");
if (session.loaded && session.user.id) {
logger("Resolving with local value.");
return $q.resolve(session);
}
export const Auth = {
isAuthenticated() {
return session.loaded && session.user.id;
},
login() {
const next = encodeURI(location.url);
logger("Calling login with next = %s", next);
window.location.href = `login?next=${next}`;
},
logout() {
logger("Logout.");
window.location.href = "logout";
},
loadSession() {
logger("Loading session");
if (session.loaded && session.user.id) {
logger("Resolving with local value.");
return Promise.resolve(session);
}
this.setApiKey(null);
return axios.get("api/session").then(data => {
updateSession(data);
return session;
Auth.setApiKey(null);
return axios.get("api/session").then(data => {
updateSession(data);
return session;
});
},
loadConfig() {
logger("Loading config");
return axios.get("/api/config").then(data => {
updateSession({ client_config: data.client_config, user: { permissions: [] }, messages: [] });
return data;
});
},
setApiKey(apiKey) {
logger("Set API key to: %s", apiKey);
Auth.apiKey = apiKey;
},
getApiKey() {
return Auth.apiKey;
},
requireSession() {
logger("Requested authentication");
if (Auth.isAuthenticated()) {
return Promise.resolve(session);
}
return Auth.loadSession()
.then(() => {
if (Auth.isAuthenticated()) {
logger("Loaded session");
return session;
}
logger("Need to login, redirecting");
Auth.login();
})
.catch(() => {
logger("Need to login, redirecting");
Auth.login();
});
},
loadConfig() {
logger("Loading config");
return axios.get("/api/config").then(data => {
updateSession({ client_config: data.client_config, user: { permissions: [] }, messages: [] });
return data;
});
},
setApiKey(apiKey) {
logger("Set API key to: %s", apiKey);
this.apiKey = apiKey;
},
getApiKey() {
return this.apiKey;
},
requireSession() {
logger("Requested authentication");
if (this.isAuthenticated()) {
return $q.when(session);
}
return this.loadSession()
.then(() => {
if (this.isAuthenticated()) {
logger("Loaded session");
return session;
}
logger("Need to login, redirecting");
this.login();
})
.catch(() => {
logger("Need to login, redirecting");
this.login();
});
},
};
}
export default function init(ngModule) {
ngModule.factory("Auth", AuthService);
ngModule.value("currentUser", currentUser);
ngModule.value("clientConfig", clientConfig);
ngModule.value("messages", messages);
ngModule.run($injector => {
Auth = $injector.get("Auth");
});
}
init.init = true;
},
};

View File

@@ -1,4 +1,5 @@
import axiosLib from "axios";
import { Auth } from "@/services/auth";
import qs from "query-string";
export const axios = axiosLib.create({
@@ -10,18 +11,11 @@ const getResponse = ({ response }) => Promise.reject(response);
axios.interceptors.response.use(getData, getResponse);
// TODO: revisit this definition when auth is updated
export default function init(ngModule) {
ngModule.run($injector => {
axios.interceptors.request.use(config => {
const apiKey = $injector.get("Auth").getApiKey();
if (apiKey) {
config.headers.Authorization = `Key ${apiKey}`;
}
axios.interceptors.request.use(config => {
const apiKey = Auth.getApiKey();
if (apiKey) {
config.headers.Authorization = `Key ${apiKey}`;
}
return config;
});
});
}
init.init = true;
return config;
});

View File

@@ -3,7 +3,8 @@ import { axios } from "@/services/axios";
import dashboardGridOptions from "@/config/dashboard-grid-options";
import Widget from "./widget";
import { currentUser } from "@/services/auth";
import { $location } from "@/services/ng";
import location from "@/services/location";
import { cloneParameter } from "@/services/parameters";
export function collectDashboardFilters(dashboard, queryResults, urlParams) {
const filters = {};
@@ -170,7 +171,7 @@ Dashboard.prototype.canEdit = function canEdit() {
Dashboard.prototype.getParametersDefs = function getParametersDefs() {
const globalParams = {};
const queryParams = $location.search();
const queryParams = location.search;
_.each(this.widgets, widget => {
if (widget.getQuery()) {
const mappings = widget.getParameterMappings();
@@ -182,7 +183,7 @@ Dashboard.prototype.getParametersDefs = function getParametersDefs() {
if (mapping.type === Widget.MappingType.DashboardLevel) {
// create global param
if (!globalParams[mapping.mapTo]) {
globalParams[mapping.mapTo] = param.clone();
globalParams[mapping.mapTo] = cloneParameter(param);
globalParams[mapping.mapTo].name = mapping.mapTo;
globalParams[mapping.mapTo].title = mapping.title || param.title;
globalParams[mapping.mapTo].locals = [];

View File

@@ -0,0 +1,99 @@
import { isNil, isUndefined, isFunction, isObject, trimStart, mapValues, omitBy, extend } from "lodash";
import qs from "query-string";
import { createBrowserHistory } from "history";
const history = createBrowserHistory();
function normalizeLocation(rawLocation) {
const { pathname, search, hash } = rawLocation;
const result = {};
result.path = pathname;
result.search = mapValues(qs.parse(search), value => (isNil(value) ? true : value));
result.hash = trimStart(hash, "#");
result.url = `${pathname}${search}${hash}`;
return result;
}
const location = {
listen(handler) {
if (isFunction(handler)) {
return history.listen((unused, action) => handler(location, action));
} else {
return () => {};
}
},
confirmChange(handler) {
if (isFunction(handler)) {
return history.block(nextLocation => {
return handler(normalizeLocation(nextLocation), location);
});
} else {
return () => {};
}
},
update(newLocation, replace = false) {
if (isObject(newLocation)) {
// remap fields and remove undefined ones
newLocation = omitBy(
{
pathname: newLocation.path,
search: newLocation.search,
hash: newLocation.hash,
},
isUndefined
);
// keep existing fields (!)
newLocation = extend(
{
pathname: location.path,
search: location.search,
hash: location.hash,
},
newLocation
);
// serialize search and keep existing search parameters (!)
if (isObject(newLocation.search)) {
newLocation.search = omitBy(extend({}, location.search, newLocation.search), isNil);
newLocation.search = mapValues(newLocation.search, value => (value === true ? null : value));
newLocation.search = qs.stringify(newLocation.search);
}
}
if (replace) {
history.replace(newLocation);
} else {
history.push(newLocation);
}
},
url: undefined,
path: undefined,
setPath(path, replace = false) {
location.update({ path }, replace);
},
search: undefined,
setSearch(search, replace = false) {
location.update({ search }, replace);
},
hash: undefined,
setHash(hash, replace = false) {
location.update({ hash }, replace);
},
};
function locationChanged() {
extend(location, normalizeLocation(history.location));
}
history.listen(locationChanged);
locationChanged(); // init service
export default location;

View File

@@ -1,21 +0,0 @@
import { isString } from "lodash";
import { $location, $rootScope, $route } from "@/services/ng";
export default function navigateTo(url, replace = false, reload = true) {
if (isString(url)) {
// Allows changing the URL without reloading
// ANGULAR_REMOVE_ME Revisit when some React router will be used
if (!reload) {
const lastRoute = $route.current;
const un = $rootScope.$on("$locationChangeSuccess", () => {
$route.current = lastRoute;
un();
});
}
$location.url(url);
if (replace) {
$location.replace();
}
$rootScope.$applyAsync();
}
}

View File

@@ -1,19 +0,0 @@
export let $http = null; // eslint-disable-line import/no-mutable-exports
export let $location = null; // eslint-disable-line import/no-mutable-exports
export let $route = null; // eslint-disable-line import/no-mutable-exports
export let $routeParams = null; // eslint-disable-line import/no-mutable-exports
export let $q = null; // eslint-disable-line import/no-mutable-exports
export let $rootScope = null; // eslint-disable-line import/no-mutable-exports
export default function init(ngModule) {
ngModule.run($injector => {
$http = $injector.get("$http");
$location = $injector.get("$location");
$route = $injector.get("$route");
$routeParams = $injector.get("$routeParams");
$q = $injector.get("$q");
$rootScope = $injector.get("$rootScope");
});
}
init.init = true;

View File

@@ -8,8 +8,8 @@ function addOnlineListener(notificationKey) {
window.addEventListener("online", onlineStateHandler);
}
export default function init(ngModule) {
ngModule.run(() => {
export default {
init() {
window.addEventListener("offline", () => {
notification.warning("Please check your Internet connection.", null, {
key: "connectionNotification",
@@ -17,7 +17,5 @@ export default function init(ngModule) {
});
addOnlineListener("connectionNotification");
});
});
}
init.init = true;
},
};

View File

@@ -1,7 +1,7 @@
import { findKey, startsWith, has, includes, isNull, values } from "lodash";
import moment from "moment";
import PropTypes from "prop-types";
import { Parameter } from ".";
import Parameter from "./Parameter";
const DATETIME_FORMATS = {
// eslint-disable-next-line quote-props

View File

@@ -1,7 +1,7 @@
import { startsWith, has, includes, findKey, values, isObject, isArray } from "lodash";
import moment from "moment";
import PropTypes from "prop-types";
import { Parameter } from ".";
import Parameter from "./Parameter";
const DATETIME_FORMATS = {
"date-range": "YYYY-MM-DD",

View File

@@ -1,5 +1,5 @@
import { isArray, isEmpty, includes, intersection, get, map, join, has } from "lodash";
import { Parameter } from ".";
import Parameter from "./Parameter";
class EnumParameter extends Parameter {
constructor(parameter, parentQueryId) {

View File

@@ -1,5 +1,5 @@
import { toNumber, isNull } from "lodash";
import { Parameter } from ".";
import Parameter from "./Parameter";
class NumberParameter extends Parameter {
constructor(parameter, parentQueryId) {

View File

@@ -1,12 +1,4 @@
import { isNull, isObject, isFunction, isUndefined, isEqual, has, omit, isArray, each } from "lodash";
import {
TextParameter,
NumberParameter,
EnumParameter,
QueryBasedDropdownParameter,
DateParameter,
DateRangeParameter,
} from ".";
class Parameter {
constructor(parameter, parentQueryId) {
@@ -23,27 +15,6 @@ class Parameter {
this.urlPrefix = "p_";
}
static create(param, parentQueryId) {
switch (param.type) {
case "number":
return new NumberParameter(param, parentQueryId);
case "enum":
return new EnumParameter(param, parentQueryId);
case "query":
return new QueryBasedDropdownParameter(param, parentQueryId);
case "date":
case "datetime-local":
case "datetime-with-seconds":
return new DateParameter(param, parentQueryId);
case "date-range":
case "datetime-range":
case "datetime-range-with-seconds":
return new DateRangeParameter(param, parentQueryId);
default:
return new TextParameter({ ...param, type: "text" }, parentQueryId);
}
}
static getExecutionValue(param, extra = {}) {
if (!isObject(param) || !isFunction(param.getExecutionValue)) {
return null;
@@ -73,10 +44,6 @@ class Parameter {
return this.$$value;
}
clone() {
return Parameter.create(this, this.parentQueryId);
}
isEmptyValue(value) {
return isNull(this.normalizeValue(value));
}

View File

@@ -1,6 +1,6 @@
import { isNull, isUndefined, isArray, isEmpty, get, map, join, has } from "lodash";
import { Query } from "@/services/query";
import { Parameter } from ".";
import Parameter from "./Parameter";
class QueryBasedDropdownParameter extends Parameter {
constructor(parameter, parentQueryId) {

View File

@@ -1,5 +1,5 @@
import { toString, isEmpty } from "lodash";
import { Parameter } from ".";
import Parameter from "./Parameter";
class TextParameter extends Parameter {
constructor(parameter, parentQueryId) {

View File

@@ -1,7 +1,44 @@
export { default as Parameter } from "./Parameter";
export { default as TextParameter } from "./TextParameter";
export { default as NumberParameter } from "./NumberParameter";
export { default as EnumParameter } from "./EnumParameter";
export { default as QueryBasedDropdownParameter } from "./QueryBasedDropdownParameter";
export { default as DateParameter } from "./DateParameter";
export { default as DateRangeParameter } from "./DateRangeParameter";
import Parameter from "./Parameter";
import TextParameter from "./TextParameter";
import NumberParameter from "./NumberParameter";
import EnumParameter from "./EnumParameter";
import QueryBasedDropdownParameter from "./QueryBasedDropdownParameter";
import DateParameter from "./DateParameter";
import DateRangeParameter from "./DateRangeParameter";
function createParameter(param, parentQueryId) {
switch (param.type) {
case "number":
return new NumberParameter(param, parentQueryId);
case "enum":
return new EnumParameter(param, parentQueryId);
case "query":
return new QueryBasedDropdownParameter(param, parentQueryId);
case "date":
case "datetime-local":
case "datetime-with-seconds":
return new DateParameter(param, parentQueryId);
case "date-range":
case "datetime-range":
case "datetime-range-with-seconds":
return new DateRangeParameter(param, parentQueryId);
default:
return new TextParameter({ ...param, type: "text" }, parentQueryId);
}
}
function cloneParameter(param) {
return createParameter(param, param.parentQueryId);
}
export {
Parameter,
TextParameter,
NumberParameter,
EnumParameter,
QueryBasedDropdownParameter,
DateParameter,
DateRangeParameter,
createParameter,
cloneParameter,
};

View File

@@ -1,4 +1,4 @@
import { Parameter } from "..";
import { createParameter } from "..";
import { getDynamicDateFromString } from "../DateParameter";
import moment from "moment";
@@ -7,7 +7,7 @@ describe("DateParameter", () => {
let param;
beforeEach(() => {
param = Parameter.create({ name: "param", title: "Param", type });
param = createParameter({ name: "param", title: "Param", type });
});
describe("getExecutionValue", () => {

View File

@@ -1,4 +1,4 @@
import { Parameter } from "..";
import { createParameter } from "..";
import { getDynamicDateRangeFromString } from "../DateRangeParameter";
import moment from "moment";
@@ -7,7 +7,7 @@ describe("DateRangeParameter", () => {
let param;
beforeEach(() => {
param = Parameter.create({ name: "param", title: "Param", type });
param = createParameter({ name: "param", title: "Param", type });
});
describe("getExecutionValue", () => {

View File

@@ -1,4 +1,4 @@
import { Parameter } from "..";
import { createParameter } from "..";
describe("EnumParameter", () => {
let param;
@@ -13,7 +13,7 @@ describe("EnumParameter", () => {
enumOptions,
multiValuesOptions,
};
param = Parameter.create(paramOptions);
param = createParameter(paramOptions);
});
describe("normalizeValue", () => {

View File

@@ -1,10 +1,10 @@
import { Parameter } from "..";
import { createParameter } from "..";
describe("NumberParameter", () => {
let param;
beforeEach(() => {
param = Parameter.create({ name: "param", title: "Param", type: "number" });
param = createParameter({ name: "param", title: "Param", type: "number" });
});
describe("normalizeValue", () => {

View File

@@ -1,5 +1,5 @@
import {
Parameter,
createParameter,
TextParameter,
NumberParameter,
EnumParameter,
@@ -25,7 +25,7 @@ describe("Parameter", () => {
];
test.each(parameterTypes)("when type is '%s' creates a %p", (type, expectedClass) => {
const parameter = Parameter.create({ name: "param", title: "Param", type });
const parameter = createParameter({ name: "param", title: "Param", type });
expect(parameter).toBeInstanceOf(expectedClass);
});
});

View File

@@ -1,4 +1,4 @@
import { Parameter } from "..";
import { createParameter } from "..";
describe("QueryBasedDropdownParameter", () => {
let param;
@@ -12,7 +12,7 @@ describe("QueryBasedDropdownParameter", () => {
queryId: 1,
multiValuesOptions,
};
param = Parameter.create(paramOptions);
param = createParameter(paramOptions);
});
describe("normalizeValue", () => {

Some files were not shown because too many files have changed in this diff Show More