Commit dd0afd0a by Torkel Ödegaard

Big refactoring for dashboard init redux actions

parent 8574dca0
import React, { FunctionComponent } from 'react';
import { AppNotificationSeverity } from 'app/types';
interface Props {
title: string;
icon?: string;
text?: string;
severity: AppNotificationSeverity;
onClose?: () => void;
}
function getIconFromSeverity(severity: AppNotificationSeverity): string {
switch (severity) {
case AppNotificationSeverity.Error: {
return 'fa fa-exclamation-triangle';
}
case AppNotificationSeverity.Success: {
return 'fa fa-check';
}
default: return null;
}
}
export const AlertBox: FunctionComponent<Props> = ({ title, icon, text, severity, onClose }) => {
return (
<div className={`alert alert-${severity}`}>
<div className="alert-icon">
<i className={icon || getIconFromSeverity(severity)} />
</div>
<div className="alert-body">
<div className="alert-title">{title}</div>
{text && <div className="alert-text">{text}</div>}
</div>
{onClose && (
<button type="button" className="alert-close" onClick={onClose}>
<i className="fa fa fa-remove" />
</button>
)}
</div>
);
};
import React, { Component } from 'react';
import { AppNotification } from 'app/types';
import { AlertBox } from '../AlertBox/AlertBox';
interface Props {
appNotification: AppNotification;
......@@ -22,18 +23,13 @@ export default class AppNotificationItem extends Component<Props> {
const { appNotification, onClearNotification } = this.props;
return (
<div className={`alert-${appNotification.severity} alert`}>
<div className="alert-icon">
<i className={appNotification.icon} />
</div>
<div className="alert-body">
<div className="alert-title">{appNotification.title}</div>
<div className="alert-text">{appNotification.text}</div>
</div>
<button type="button" className="alert-close" onClick={() => onClearNotification(appNotification.id)}>
<i className="fa fa fa-remove" />
</button>
</div>
<AlertBox
severity={appNotification.severity}
title={appNotification.title}
text={appNotification.text}
icon={appNotification.icon}
onClose={() => onClearNotification(appNotification.id)}
/>
);
}
}
import _ from 'lodash';
import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types';
import { getMessageFromError } from 'app/core/utils/errors';
const defaultSuccessNotification: AppNotification = {
title: '',
......@@ -33,21 +33,10 @@ export const createSuccessNotification = (title: string, text?: string): AppNoti
});
export const createErrorNotification = (title: string, text?: any): AppNotification => {
// Handling if text is an error object
if (text && !_.isString(text)) {
if (text.message) {
text = text.message;
} else if (text.data && text.data.message) {
text = text.data.message;
} else {
text = text.toString();
}
}
return {
...defaultErrorNotification,
title: title,
text: text,
text: getMessageFromError(text),
id: Date.now(),
};
};
......
import _ from 'lodash';
export function getMessageFromError(err: any): string | null {
if (err && !_.isString(err)) {
if (err.message) {
return err.message;
} else if (err.data && err.data.message) {
return err.data.message;
} else {
return JSON.stringify(err);
}
}
return null;
}
import moment from 'moment';
import angular from 'angular';
import { appEvents, NavModel } from 'app/core/core';
import { DashboardModel } from '../../state/DashboardModel';
export class DashNavCtrl {
dashboard: DashboardModel;
navModel: NavModel;
titleTooltip: string;
/** @ngInject */
constructor(private $scope, private dashboardSrv, private $location, public playlistSrv) {
if (this.dashboard.meta.isSnapshot) {
const meta = this.dashboard.meta;
this.titleTooltip = 'Created: &nbsp;' + moment(meta.created).calendar();
if (meta.expires) {
this.titleTooltip += '<br>Expires: &nbsp;' + moment(meta.expires).fromNow() + '<br>';
}
}
}
toggleSettings() {
const search = this.$location.search();
if (search.editview) {
delete search.editview;
} else {
search.editview = 'settings';
}
this.$location.search(search);
}
toggleViewMode() {
appEvents.emit('toggle-kiosk-mode');
}
close() {
const search = this.$location.search();
if (search.editview) {
delete search.editview;
} else if (search.fullscreen) {
delete search.fullscreen;
delete search.edit;
delete search.tab;
delete search.panelId;
}
this.$location.search(search);
}
starDashboard() {
this.dashboardSrv.starDashboard(this.dashboard.id, this.dashboard.meta.isStarred).then(newState => {
this.dashboard.meta.isStarred = newState;
});
}
shareDashboard(tabIndex) {
const modalScope = this.$scope.$new();
modalScope.tabIndex = tabIndex;
modalScope.dashboard = this.dashboard;
appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: modalScope,
});
}
hideTooltip(evt) {
angular.element(evt.currentTarget).tooltip('hide');
}
saveDashboard() {
return this.dashboardSrv.saveDashboard();
}
showSearch() {
if (this.dashboard.meta.fullscreen) {
this.close();
return;
}
appEvents.emit('show-dash-search');
}
addPanel() {
appEvents.emit('dash-scroll', { animate: true, evt: 0 });
if (this.dashboard.panels.length > 0 && this.dashboard.panels[0].type === 'add-panel') {
return; // Return if the "Add panel" exists already
}
this.dashboard.addPanel({
type: 'add-panel',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
title: 'Panel Title',
});
}
navItemClicked(navItem, evt) {
if (navItem.clickHandler) {
navItem.clickHandler();
evt.preventDefault();
}
}
}
export function dashNavDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/components/DashNav/template.html',
controller: DashNavCtrl,
bindToController: true,
controllerAs: 'ctrl',
transclude: true,
scope: { dashboard: '=' },
};
}
angular.module('grafana.directives').directive('dashnav', dashNavDirective);
export { DashNavCtrl } from './DashNavCtrl';
import DashNav from './DashNav';
export { DashNav };
......@@ -2,8 +2,8 @@ import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { DashboardPage, Props, State } from './DashboardPage';
import { DashboardModel } from '../state';
import { setDashboardModel } from '../state/actions';
import { DashboardRouteInfo, DashboardLoadingState } from 'app/types';
import { cleanUpDashboard } from '../state/actions';
import { DashboardRouteInfo, DashboardInitPhase } from 'app/types';
jest.mock('sass/_variables.scss', () => ({
panelhorizontalpadding: 10,
......@@ -22,13 +22,13 @@ function setup(propOverrides?: Partial<Props>): ShallowWrapper<Props, State, Das
routeInfo: DashboardRouteInfo.Normal,
urlEdit: false,
urlFullscreen: false,
loadingState: DashboardLoadingState.Done,
isLoadingSlow: false,
initPhase: DashboardInitPhase.Completed,
isInitSlow: false,
initDashboard: jest.fn(),
updateLocation: jest.fn(),
notifyApp: jest.fn(),
dashboard: null,
setDashboardModel: setDashboardModel,
cleanUpDashboard: cleanUpDashboard,
};
Object.assign(props, propOverrides);
......@@ -66,7 +66,7 @@ describe('DashboardPage', () => {
canEdit: true,
canSave: true,
});
wrapper.setProps({ dashboard, loadingState: DashboardLoadingState.Done });
wrapper.setProps({ dashboard, initPhase: DashboardInitPhase.Completed });
});
it('Should update title', () => {
......
......@@ -7,6 +7,7 @@ import classNames from 'classnames';
// Services & Utils
import { createErrorNotification } from 'app/core/copy/appNotification';
import { getMessageFromError } from 'app/core/utils/errors';
// Components
import { DashboardGrid } from '../dashgrid/DashboardGrid';
......@@ -14,15 +15,22 @@ import { DashNav } from '../components/DashNav';
import { SubMenu } from '../components/SubMenu';
import { DashboardSettings } from '../components/DashboardSettings';
import { CustomScrollbar } from '@grafana/ui';
import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
// Redux
import { initDashboard } from '../state/initDashboard';
import { setDashboardModel } from '../state/actions';
import { cleanUpDashboard } from '../state/actions';
import { updateLocation } from 'app/core/actions';
import { notifyApp } from 'app/core/actions';
// Types
import { StoreState, DashboardLoadingState, DashboardRouteInfo } from 'app/types';
import {
StoreState,
DashboardInitPhase,
DashboardRouteInfo,
DashboardInitError,
AppNotificationSeverity,
} from 'app/types';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
export interface Props {
......@@ -37,11 +45,12 @@ export interface Props {
routeInfo: DashboardRouteInfo;
urlEdit: boolean;
urlFullscreen: boolean;
loadingState: DashboardLoadingState;
isLoadingSlow: boolean;
initPhase: DashboardInitPhase;
isInitSlow: boolean;
dashboard: DashboardModel | null;
initError?: DashboardInitError;
initDashboard: typeof initDashboard;
setDashboardModel: typeof setDashboardModel;
cleanUpDashboard: typeof cleanUpDashboard;
notifyApp: typeof notifyApp;
updateLocation: typeof updateLocation;
}
......@@ -83,7 +92,7 @@ export class DashboardPage extends PureComponent<Props, State> {
componentWillUnmount() {
if (this.props.dashboard) {
this.props.dashboard.destroy();
this.props.setDashboardModel(null);
this.props.cleanUpDashboard();
}
}
......@@ -204,23 +213,37 @@ export class DashboardPage extends PureComponent<Props, State> {
this.setState({ scrollTop: 0 });
};
renderLoadingState() {
renderSlowInitState() {
return (
<div className="dashboard-loading">
<div className="dashboard-loading__text">
<i className="fa fa-spinner fa-spin" /> Dashboard {this.props.loadingState}
<i className="fa fa-spinner fa-spin" /> {this.props.initPhase}
</div>
</div>
);
}
renderInitFailedState() {
const { initError } = this.props;
return (
<div className="dashboard-loading">
<AlertBox
severity={AppNotificationSeverity.Error}
title={initError.message}
text={getMessageFromError(initError.error)}
/>
</div>
);
}
render() {
const { dashboard, editview, $injector, isLoadingSlow } = this.props;
const { dashboard, editview, $injector, isInitSlow, initError } = this.props;
const { isSettingsOpening, isEditing, isFullscreen, scrollTop } = this.state;
if (!dashboard) {
if (isLoadingSlow) {
return this.renderLoadingState();
if (isInitSlow) {
return this.renderSlowInitState();
}
return null;
}
......@@ -249,6 +272,8 @@ export class DashboardPage extends PureComponent<Props, State> {
<CustomScrollbar autoHeightMin={'100%'} setScrollTop={this.setScrollTop} scrollTop={scrollTop}>
{editview && <DashboardSettings dashboard={dashboard} />}
{initError && this.renderInitFailedState()}
<div className={gridWrapperClasses}>
{dashboard.meta.submenuEnabled && <SubMenu dashboard={dashboard} />}
<DashboardGrid dashboard={dashboard} isEditing={isEditing} isFullscreen={isFullscreen} />
......@@ -269,14 +294,15 @@ const mapStateToProps = (state: StoreState) => ({
urlFolderId: state.location.query.folderId,
urlFullscreen: state.location.query.fullscreen === true,
urlEdit: state.location.query.edit === true,
loadingState: state.dashboard.loadingState,
isLoadingSlow: state.dashboard.isLoadingSlow,
initPhase: state.dashboard.initPhase,
isInitSlow: state.dashboard.isInitSlow,
initError: state.dashboard.initError,
dashboard: state.dashboard.model as DashboardModel,
});
const mapDispatchToProps = {
initDashboard,
setDashboardModel,
cleanUpDashboard,
notifyApp,
updateLocation,
};
......
......@@ -100,7 +100,6 @@ const mapStateToProps = (state: StoreState) => ({
urlSlug: state.location.routeParams.slug,
urlType: state.location.routeParams.type,
urlPanelId: state.location.query.panelId,
loadingState: state.dashboard.loadingState,
dashboard: state.dashboard.model as DashboardModel,
});
......
......@@ -8,20 +8,36 @@ import { loadPluginDashboards } from '../../plugins/state/actions';
import { notifyApp } from 'app/core/actions';
// Types
import { ThunkResult } from 'app/types';
import {
ThunkResult,
DashboardAcl,
DashboardAclDTO,
PermissionLevel,
DashboardAclUpdateDTO,
NewDashboardAclItem,
} from 'app/types/acl';
import { DashboardLoadingState, MutableDashboard } from 'app/types/dashboard';
MutableDashboard,
DashboardInitError,
} from 'app/types';
export const loadDashboardPermissions = actionCreatorFactory<DashboardAclDTO[]>('LOAD_DASHBOARD_PERMISSIONS').create();
export const setDashboardLoadingState = actionCreatorFactory<DashboardLoadingState>('SET_DASHBOARD_LOADING_STATE').create();
export const setDashboardModel = actionCreatorFactory<MutableDashboard>('SET_DASHBOARD_MODEL').create();
export const setDashboardLoadingSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_LOADING_SLOW').create();
export const dashboardInitFetching = noPayloadActionCreatorFactory('DASHBOARD_INIT_FETCHING').create();
export const dashboardInitServices = noPayloadActionCreatorFactory('DASHBOARD_INIT_SERVICES').create();
export const dashboardInitSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_INIT_SLOW').create();
export const dashboardInitCompleted = actionCreatorFactory<MutableDashboard>('DASHBOARD_INIT_COMLETED').create();
/*
* Unrecoverable init failure (fetch or model creation failed)
*/
export const dashboardInitFailed = actionCreatorFactory<DashboardInitError>('DASHBOARD_INIT_FAILED').create();
/*
* When leaving dashboard, resets state
* */
export const cleanUpDashboard = noPayloadActionCreatorFactory('DASHBOARD_CLEAN_UP').create();
export function getDashboardPermissions(id: number): ThunkResult<void> {
return async dispatch => {
......
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { initDashboard, InitDashboardArgs } from './initDashboard';
import { DashboardRouteInfo, DashboardLoadingState } from 'app/types';
import { DashboardRouteInfo } from 'app/types';
const mockStore = configureMockStore([thunk]);
......@@ -98,13 +98,11 @@ describeInitScenario('Initializing new dashboard', ctx => {
});
it('Should send action to set loading state to fetching', () => {
expect(ctx.actions[0].type).toBe('SET_DASHBOARD_LOADING_STATE');
expect(ctx.actions[0].payload).toBe(DashboardLoadingState.Fetching);
expect(ctx.actions[0].type).toBe('DASHBOARD_INIT_FETCHING');
});
it('Should send action to set loading state to Initializing', () => {
expect(ctx.actions[1].type).toBe('SET_DASHBOARD_LOADING_STATE');
expect(ctx.actions[1].payload).toBe(DashboardLoadingState.Initializing);
expect(ctx.actions[1].type).toBe('DASHBOARD_INIT_SERVICES');
});
it('Should update location with orgId query param', () => {
......@@ -113,7 +111,7 @@ describeInitScenario('Initializing new dashboard', ctx => {
});
it('Should send action to set dashboard model', () => {
expect(ctx.actions[3].type).toBe('SET_DASHBOARD_MODEL');
expect(ctx.actions[3].type).toBe('DASHBOARD_INIT_COMLETED');
expect(ctx.actions[3].payload.title).toBe('New dashboard');
});
......
......@@ -12,17 +12,16 @@ import { KeybindingSrv } from 'app/core/services/keybindingSrv';
import { updateLocation } from 'app/core/actions';
import { notifyApp } from 'app/core/actions';
import locationUtil from 'app/core/utils/location_util';
import { setDashboardLoadingState, setDashboardModel, setDashboardLoadingSlow } from './actions';
import {
dashboardInitFetching,
dashboardInitCompleted,
dashboardInitFailed,
dashboardInitSlow,
dashboardInitServices,
} from './actions';
// Types
import {
DashboardLoadingState,
DashboardRouteInfo,
StoreState,
ThunkDispatch,
ThunkResult,
DashboardDTO,
} from 'app/types';
import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO } from 'app/types';
import { DashboardModel } from './DashboardModel';
export interface InitDashboardArgs {
......@@ -106,8 +105,7 @@ async function fetchDashboard(
throw { message: 'Unknown route ' + args.routeInfo };
}
} catch (err) {
dispatch(setDashboardLoadingState(DashboardLoadingState.Error));
dispatch(notifyApp(createErrorNotification('Dashboard fetch failed', err)));
dispatch(dashboardInitFailed({ message: 'Failed to fetch dashboard', error: err }));
console.log(err);
return null;
}
......@@ -125,13 +123,13 @@ async function fetchDashboard(
export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
return async (dispatch, getState) => {
// set fetching state
dispatch(setDashboardLoadingState(DashboardLoadingState.Fetching));
dispatch(dashboardInitFetching());
// Detect slow loading / initializing and set state flag
// This is in order to not show loading indication for fast loading dashboards as it creates blinking/flashing
setTimeout(() => {
if (getState().dashboard.model === null) {
dispatch(setDashboardLoadingSlow());
dispatch(dashboardInitSlow());
}
}, 500);
......@@ -144,15 +142,14 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
}
// set initializing state
dispatch(setDashboardLoadingState(DashboardLoadingState.Initializing));
dispatch(dashboardInitServices());
// create model
let dashboard: DashboardModel;
try {
dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta);
} catch (err) {
dispatch(setDashboardLoadingState(DashboardLoadingState.Error));
dispatch(notifyApp(createErrorNotification('Dashboard model initializing failure', err)));
dispatch(dashboardInitFailed({ message: 'Failed create dashboard model', error: err }));
console.log(err);
return;
}
......@@ -203,8 +200,8 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
// legacy srv state
dashboardSrv.setCurrent(dashboard);
// set model in redux (even though it's mutable)
dispatch(setDashboardModel(dashboard));
// yay we are done
dispatch(dashboardInitCompleted(dashboard));
};
}
......
import { DashboardState, DashboardLoadingState } from 'app/types/dashboard';
import { loadDashboardPermissions, setDashboardLoadingState, setDashboardModel, setDashboardLoadingSlow } from './actions';
import { DashboardState, DashboardInitPhase } from 'app/types';
import {
loadDashboardPermissions,
dashboardInitFetching,
dashboardInitSlow,
dashboardInitServices,
dashboardInitFailed,
dashboardInitCompleted,
cleanUpDashboard,
} from './actions';
import { reducerFactory } from 'app/core/redux';
import { processAclItems } from 'app/core/utils/acl';
import { DashboardModel } from './DashboardModel';
export const initialState: DashboardState = {
loadingState: DashboardLoadingState.NotStarted,
isLoadingSlow: false,
initPhase: DashboardInitPhase.NotStarted,
isInitSlow: false,
model: null,
permissions: [],
};
......@@ -19,27 +28,60 @@ export const dashboardReducer = reducerFactory(initialState)
}),
})
.addMapper({
filter: setDashboardLoadingState,
mapper: (state, action) => ({
filter: dashboardInitFetching,
mapper: state => ({
...state,
initPhase: DashboardInitPhase.Fetching,
}),
})
.addMapper({
filter: dashboardInitServices,
mapper: state => ({
...state,
initPhase: DashboardInitPhase.Services,
}),
})
.addMapper({
filter: dashboardInitSlow,
mapper: state => ({
...state,
loadingState: action.payload
isInitSlow: true,
}),
})
.addMapper({
filter: setDashboardModel,
filter: dashboardInitFailed,
mapper: (state, action) => ({
...state,
model: action.payload,
isLoadingSlow: false,
initPhase: DashboardInitPhase.Failed,
isInitSlow: false,
initError: action.payload,
model: new DashboardModel({ title: 'Dashboard init failed' }, { canSave: false, canEdit: false }),
}),
})
.addMapper({
filter: setDashboardLoadingSlow,
filter: dashboardInitCompleted,
mapper: (state, action) => ({
...state,
isLoadingSlow: true,
initPhase: DashboardInitPhase.Completed,
model: action.payload,
isInitSlow: false,
}),
})
.addMapper({
filter: cleanUpDashboard,
mapper: (state, action) => {
// tear down current dashboard
state.model.destroy();
return {
...state,
initPhase: DashboardInitPhase.NotStarted,
model: null,
isInitSlow: false,
initError: null,
};
},
})
.create();
export default {
......
......@@ -2,6 +2,7 @@ import { DashboardAcl } from './acl';
export interface MutableDashboard {
meta: DashboardMeta;
destroy: () => void;
}
export interface DashboardDTO {
......@@ -44,12 +45,17 @@ export enum DashboardRouteInfo {
Scripted = 'scripted-dashboard',
}
export enum DashboardLoadingState {
export enum DashboardInitPhase {
NotStarted = 'Not started',
Fetching = 'Fetching',
Initializing = 'Initializing',
Error = 'Error',
Done = 'Done',
Services = 'Services',
Failed = 'Failed',
Completed = 'Completed',
}
export interface DashboardInitError {
message: string;
error: any;
}
export const KIOSK_MODE_TV = 'tv';
......@@ -57,7 +63,8 @@ export type KioskUrlValue = 'tv' | '1' | true;
export interface DashboardState {
model: MutableDashboard | null;
loadingState: DashboardLoadingState;
isLoadingSlow: boolean;
initPhase: DashboardInitPhase;
isInitSlow: boolean;
initError?: DashboardInitError;
permissions: DashboardAcl[] | null;
}
......@@ -282,6 +282,11 @@ div.flot-text {
display: flex;
align-items: center;
justify-content: center;
.alert {
max-width: 600px;
min-width: 600px;
}
}
.dashboard-loading__text {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment