Merge pull request #15212 from grafana/dashboard-react-page

Dashboard react page
This commit is contained in:
Torkel Ödegaard 2019-02-07 14:23:44 +01:00 committed by GitHub
commit 78ea7ae783
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 2549 additions and 951 deletions

View File

@ -85,6 +85,7 @@
"prettier": "1.9.2",
"react-hot-loader": "^4.3.6",
"react-test-renderer": "^16.5.0",
"redux-mock-store": "^1.5.3",
"regexp-replace-loader": "^1.0.1",
"sass-lint": "^1.10.2",
"sass-loader": "^7.0.1",

View File

@ -5,6 +5,7 @@ type PlaylistDashboard struct {
Slug string `json:"slug"`
Title string `json:"title"`
Uri string `json:"uri"`
Url string `json:"url"`
Order int `json:"order"`
}

View File

@ -26,6 +26,7 @@ func populateDashboardsByID(dashboardByIDs []int64, dashboardIDOrder map[int64]i
Slug: item.Slug,
Title: item.Title,
Uri: "db/" + item.Slug,
Url: m.GetDashboardUrl(item.Uid, item.Slug),
Order: dashboardIDOrder[item.Id],
})
}

View File

@ -1,4 +1,5 @@
import { Emitter } from './utils/emitter';
const appEvents = new Emitter();
export const appEvents = new Emitter();
export default appEvents;

View File

@ -0,0 +1,42 @@
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>
);
};

View File

@ -1,5 +1,6 @@
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)}
/>
);
}
}

View File

@ -17,13 +17,10 @@ interface Props {
}
class Page extends Component<Props> {
private bodyClass = 'is-react';
private body = document.body;
static Header = PageHeader;
static Contents = PageContents;
componentDidMount() {
this.body.classList.add(this.bodyClass);
this.updateTitle();
}
@ -33,10 +30,6 @@ class Page extends Component<Props> {
}
}
componentWillUnmount() {
this.body.classList.remove(this.bodyClass);
}
updateTitle = () => {
const title = this.getPageTitle;
document.title = title ? title + ' - Grafana' : 'Grafana';

View File

@ -1,40 +0,0 @@
import coreModule from 'app/core/core_module';
const template = `
<div class="scroll-canvas">
<navbar model="model"></navbar>
<div class="page-container">
<div class="page-header">
<h1>
<i class="{{::model.node.icon}}" ng-if="::model.node.icon"></i>
<img ng-src="{{::model.node.img}}" ng-if="::model.node.img"></i>
{{::model.node.text}}
</h1>
<div class="page-header__actions" ng-transclude="header"></div>
</div>
<div class="page-body" ng-transclude="body">
</div>
</div>
</div>
`;
export function gfPageDirective() {
return {
restrict: 'E',
template: template,
scope: {
model: '=',
},
transclude: {
header: '?gfPageHeader',
body: 'gfPageBody',
},
link: (scope, elem, attrs) => {
console.log(scope);
},
};
}
coreModule.directive('gfPage', gfPageDirective);

View File

@ -1,43 +0,0 @@
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
export function pageScrollbar() {
return {
restrict: 'A',
link: (scope, elem, attrs) => {
let lastPos = 0;
appEvents.on(
'dash-scroll',
evt => {
if (evt.restore) {
elem[0].scrollTop = lastPos;
return;
}
lastPos = elem[0].scrollTop;
if (evt.animate) {
elem.animate({ scrollTop: evt.pos }, 500);
} else {
elem[0].scrollTop = evt.pos;
}
},
scope
);
scope.$on('$routeChangeSuccess', () => {
lastPos = 0;
elem[0].scrollTop = 0;
// Focus page to enable scrolling by keyboard
elem[0].focus({ preventScroll: true });
});
elem[0].tabIndex = -1;
// Focus page to enable scrolling by keyboard
elem[0].focus({ preventScroll: true });
},
};
}
coreModule.directive('pageScrollbar', pageScrollbar);

View File

@ -68,5 +68,5 @@ const bootData = (window as any).grafanaBootData || {
const options = bootData.settings;
options.bootData = bootData;
const config = new Settings(options);
export const config = new Settings(options);
export default config;

View File

@ -1,4 +1,5 @@
import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types';
import { getMessageFromError } from 'app/core/utils/errors';
const defaultSuccessNotification: AppNotification = {
title: '',
@ -31,12 +32,14 @@ export const createSuccessNotification = (title: string, text?: string): AppNoti
id: Date.now(),
});
export const createErrorNotification = (title: string, text?: string): AppNotification => ({
...defaultErrorNotification,
title: title,
text: text,
id: Date.now(),
});
export const createErrorNotification = (title: string, text?: any): AppNotification => {
return {
...defaultErrorNotification,
title: title,
text: getMessageFromError(text),
id: Date.now(),
};
};
export const createWarningNotification = (title: string, text?: string): AppNotification => ({
...defaultWarningNotification,

View File

@ -43,8 +43,6 @@ import { helpModal } from './components/help/help';
import { JsonExplorer } from './components/json_explorer/json_explorer';
import { NavModelSrv, NavModel } from './nav_model_srv';
import { geminiScrollbar } from './components/scroll/scroll';
import { pageScrollbar } from './components/scroll/page_scroll';
import { gfPageDirective } from './components/gf_page';
import { orgSwitcher } from './components/org_switcher';
import { profiler } from './profiler';
import { registerAngularDirectives } from './angular_wrappers';
@ -79,8 +77,6 @@ export {
NavModelSrv,
NavModel,
geminiScrollbar,
pageScrollbar,
gfPageDirective,
orgSwitcher,
manageDashboardsDirective,
TimeSeries,

View File

@ -8,12 +8,13 @@ export const initialState: LocationState = {
path: '',
query: {},
routeParams: {},
replace: false,
};
export const locationReducer = (state = initialState, action: Action): LocationState => {
switch (action.type) {
case CoreActionTypes.UpdateLocation: {
const { path, routeParams } = action.payload;
const { path, routeParams, replace } = action.payload;
let query = action.payload.query || state.query;
if (action.payload.partial) {
@ -26,6 +27,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS
path: path || state.path,
query: { ...query },
routeParams: routeParams || state.routeParams,
replace: replace === true,
};
}
}

View File

@ -53,5 +53,20 @@ export const noPayloadActionCreatorFactory = (type: string): NoPayloadActionCrea
return { create };
};
export interface NoPayloadActionCreatorMock extends NoPayloadActionCreator {
calls: number;
}
export const getNoPayloadActionCreatorMock = (creator: NoPayloadActionCreator): NoPayloadActionCreatorMock => {
const mock: NoPayloadActionCreatorMock = Object.assign(
(): ActionOf<undefined> => {
mock.calls++;
return { type: creator.type, payload: undefined };
},
{ type: creator.type, calls: 0 }
);
return mock;
};
// Should only be used by tests
export const resetAllActionCreatorTypes = () => (allActionCreators.length = 0);

View File

@ -1,4 +1,2 @@
import { actionCreatorFactory } from './actionCreatorFactory';
import { reducerFactory } from './reducerFactory';
export { actionCreatorFactory, reducerFactory };
export * from './actionCreatorFactory';
export * from './reducerFactory';

View File

@ -0,0 +1,14 @@
const backendSrv = {
get: jest.fn(),
getDashboard: jest.fn(),
getDashboardByUid: jest.fn(),
getFolderByUid: jest.fn(),
post: jest.fn(),
};
export function getBackendSrv() {
return backendSrv;
}

View File

@ -46,6 +46,10 @@ export class BridgeSrv {
if (angularUrl !== url) {
this.$timeout(() => {
this.$location.url(url);
// some state changes should not trigger new browser history
if (state.location.replace) {
this.$location.replace();
}
});
console.log('store updating angular $location.url', url);
}

View File

@ -104,7 +104,7 @@ export class KeybindingSrv {
}
if (search.fullscreen) {
this.$rootScope.appEvent('panel-change-view', { fullscreen: false, edit: false });
appEvents.emit('panel-change-view', { fullscreen: false, edit: false });
return;
}
@ -174,7 +174,7 @@ export class KeybindingSrv {
// edit panel
this.bind('e', () => {
if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
this.$rootScope.appEvent('panel-change-view', {
appEvents.emit('panel-change-view', {
fullscreen: true,
edit: true,
panelId: dashboard.meta.focusPanelId,
@ -186,7 +186,7 @@ export class KeybindingSrv {
// view panel
this.bind('v', () => {
if (dashboard.meta.focusPanelId) {
this.$rootScope.appEvent('panel-change-view', {
appEvents.emit('panel-change-view', {
fullscreen: true,
edit: null,
panelId: dashboard.meta.focusPanelId,
@ -212,9 +212,7 @@ export class KeybindingSrv {
// delete panel
this.bind('p r', () => {
if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
this.$rootScope.appEvent('panel-remove', {
panelId: dashboard.meta.focusPanelId,
});
appEvents.emit('remove-panel', dashboard.meta.focusPanelId);
dashboard.meta.focusPanelId = 0;
}
});

View File

@ -0,0 +1,17 @@
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 if (err.statusText) {
return err.statusText;
} else {
return JSON.stringify(err);
}
}
return null;
}

View File

@ -1,6 +1,6 @@
import config from 'app/core/config';
export const stripBaseFromUrl = url => {
export const stripBaseFromUrl = (url: string): string => {
const appSubUrl = config.appSubUrl;
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
const urlWithoutBase =

View File

@ -2,6 +2,7 @@ import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
import coreModule from 'app/core/core_module';
import { DashboardModel } from 'app/features/dashboard/state';
export class AnnotationsEditorCtrl {
mode: any;
@ -10,6 +11,7 @@ export class AnnotationsEditorCtrl {
currentAnnotation: any;
currentDatasource: any;
currentIsNew: any;
dashboard: DashboardModel;
annotationDefaults: any = {
name: '',
@ -26,9 +28,10 @@ export class AnnotationsEditorCtrl {
constructor($scope, private datasourceSrv) {
$scope.ctrl = this;
this.dashboard = $scope.dashboard;
this.mode = 'list';
this.datasources = datasourceSrv.getAnnotationSources();
this.annotations = $scope.dashboard.annotations.list;
this.annotations = this.dashboard.annotations.list;
this.reset();
this.onColorChange = this.onColorChange.bind(this);
@ -78,11 +81,13 @@ export class AnnotationsEditorCtrl {
this.annotations.push(this.currentAnnotation);
this.reset();
this.mode = 'list';
this.dashboard.updateSubmenuVisibility();
}
removeAnnotation(annotation) {
const index = _.indexOf(this.annotations, annotation);
this.annotations.splice(index, 1);
this.dashboard.updateSubmenuVisibility();
}
onColorChange(newColor) {

View File

@ -1,10 +1,12 @@
import _ from 'lodash';
import angular from 'angular';
import coreModule from 'app/core/core_module';
import { DashboardModel } from 'app/features/dashboard/state';
export class AdHocFiltersCtrl {
segments: any;
variable: any;
dashboard: DashboardModel;
removeTagFilterSegment: any;
/** @ngInject */
@ -14,14 +16,13 @@ export class AdHocFiltersCtrl {
private $q,
private variableSrv,
$scope,
private $rootScope
) {
this.removeTagFilterSegment = uiSegmentSrv.newSegment({
fake: true,
value: '-- remove filter --',
});
this.buildSegmentModel();
this.$rootScope.onAppEvent('template-variable-value-updated', this.buildSegmentModel.bind(this), $scope);
this.dashboard.events.on('template-variable-value-updated', this.buildSegmentModel.bind(this), $scope);
}
buildSegmentModel() {
@ -171,6 +172,7 @@ export function adHocFiltersComponent() {
controllerAs: 'ctrl',
scope: {
variable: '=',
dashboard: '=',
},
};
}

View File

@ -1,5 +1,6 @@
import angular from 'angular';
import _ from 'lodash';
import { DashboardModel } from 'app/features/dashboard/state';
export let iconMap = {
'external link': 'fa-external-link',
@ -12,7 +13,7 @@ export let iconMap = {
};
export class DashLinksEditorCtrl {
dashboard: any;
dashboard: DashboardModel;
iconMap: any;
mode: any;
link: any;
@ -40,6 +41,7 @@ export class DashLinksEditorCtrl {
addLink() {
this.dashboard.links.push(this.link);
this.mode = 'list';
this.dashboard.updateSubmenuVisibility();
}
editLink(link) {

View File

@ -0,0 +1,253 @@
// Libaries
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
// Utils & Services
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
import { appEvents } from 'app/core/app_events';
import { PlaylistSrv } from 'app/features/playlist/playlist_srv';
// Components
import { DashNavButton } from './DashNavButton';
// State
import { updateLocation } from 'app/core/actions';
// Types
import { DashboardModel } from '../../state/DashboardModel';
export interface Props {
dashboard: DashboardModel;
editview: string;
isEditing: boolean;
isFullscreen: boolean;
$injector: any;
updateLocation: typeof updateLocation;
onAddPanel: () => void;
}
export class DashNav extends PureComponent<Props> {
timePickerEl: HTMLElement;
timepickerCmp: AngularComponent;
playlistSrv: PlaylistSrv;
constructor(props: Props) {
super(props);
this.playlistSrv = this.props.$injector.get('playlistSrv');
}
componentDidMount() {
const loader = getAngularLoader();
const template =
'<gf-time-picker class="gf-timepicker-nav" dashboard="dashboard" ng-if="!dashboard.timepicker.hidden" />';
const scopeProps = { dashboard: this.props.dashboard };
this.timepickerCmp = loader.load(this.timePickerEl, scopeProps, template);
}
componentWillUnmount() {
if (this.timepickerCmp) {
this.timepickerCmp.destroy();
}
}
onOpenSearch = () => {
appEvents.emit('show-dash-search');
};
onClose = () => {
if (this.props.editview) {
this.props.updateLocation({
query: { editview: null },
partial: true,
});
} else {
this.props.updateLocation({
query: { panelId: null, edit: null, fullscreen: null },
partial: true,
});
}
};
onToggleTVMode = () => {
appEvents.emit('toggle-kiosk-mode');
};
onSave = () => {
const { $injector } = this.props;
const dashboardSrv = $injector.get('dashboardSrv');
dashboardSrv.saveDashboard();
};
onOpenSettings = () => {
this.props.updateLocation({
query: { editview: 'settings' },
partial: true,
});
};
onStarDashboard = () => {
const { dashboard, $injector } = this.props;
const dashboardSrv = $injector.get('dashboardSrv');
dashboardSrv.starDashboard(dashboard.id, dashboard.meta.isStarred).then(newState => {
dashboard.meta.isStarred = newState;
this.forceUpdate();
});
};
onPlaylistPrev = () => {
this.playlistSrv.prev();
};
onPlaylistNext = () => {
this.playlistSrv.next();
};
onPlaylistStop = () => {
this.playlistSrv.stop();
this.forceUpdate();
};
onOpenShare = () => {
const $rootScope = this.props.$injector.get('$rootScope');
const modalScope = $rootScope.$new();
modalScope.tabIndex = 0;
modalScope.dashboard = this.props.dashboard;
appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: modalScope,
});
};
render() {
const { dashboard, isFullscreen, editview, onAddPanel } = this.props;
const { canStar, canSave, canShare, folderTitle, showSettings, isStarred } = dashboard.meta;
const { snapshot } = dashboard;
const haveFolder = dashboard.meta.folderId > 0;
const snapshotUrl = snapshot && snapshot.originalUrl;
return (
<div className="navbar">
<div>
<a className="navbar-page-btn" onClick={this.onOpenSearch}>
<i className="gicon gicon-dashboard" />
{haveFolder && <span className="navbar-page-btn--folder">{folderTitle} / </span>}
{dashboard.title}
<i className="fa fa-caret-down" />
</a>
</div>
<div className="navbar__spacer" />
{this.playlistSrv.isPlaying && (
<div className="navbar-buttons navbar-buttons--playlist">
<DashNavButton
tooltip="Go to previous dashboard"
classSuffix="tight"
icon="fa fa-step-backward"
onClick={this.onPlaylistPrev}
/>
<DashNavButton
tooltip="Stop playlist"
classSuffix="tight"
icon="fa fa-stop"
onClick={this.onPlaylistStop}
/>
<DashNavButton
tooltip="Go to next dashboard"
classSuffix="tight"
icon="fa fa-forward"
onClick={this.onPlaylistNext}
/>
</div>
)}
<div className="navbar-buttons navbar-buttons--actions">
{canSave && (
<DashNavButton
tooltip="Add panel"
classSuffix="add-panel"
icon="gicon gicon-add-panel"
onClick={onAddPanel}
/>
)}
{canStar && (
<DashNavButton
tooltip="Mark as favorite"
classSuffix="star"
icon={`${isStarred ? 'fa fa-star' : 'fa fa-star-o'}`}
onClick={this.onStarDashboard}
/>
)}
{canShare && (
<DashNavButton
tooltip="Share dashboard"
classSuffix="share"
icon="fa fa-share-square-o"
onClick={this.onOpenShare}
/>
)}
{canSave && (
<DashNavButton tooltip="Save dashboard" classSuffix="save" icon="fa fa-save" onClick={this.onSave} />
)}
{snapshotUrl && (
<DashNavButton
tooltip="Open original dashboard"
classSuffix="snapshot-origin"
icon="fa fa-link"
href={snapshotUrl}
/>
)}
{showSettings && (
<DashNavButton
tooltip="Dashboard settings"
classSuffix="settings"
icon="fa fa-cog"
onClick={this.onOpenSettings}
/>
)}
</div>
<div className="navbar-buttons navbar-buttons--tv">
<DashNavButton
tooltip="Cycke view mode"
classSuffix="tv"
icon="fa fa-desktop"
onClick={this.onToggleTVMode}
/>
</div>
<div className="gf-timepicker-nav" ref={element => (this.timePickerEl = element)} />
{(isFullscreen || editview) && (
<div className="navbar-buttons navbar-buttons--close">
<DashNavButton
tooltip="Back to dashboard"
classSuffix="primary"
icon="fa fa-reply"
onClick={this.onClose}
/>
</div>
)}
</div>
);
}
}
const mapStateToProps = () => ({});
const mapDispatchToProps = {
updateLocation,
};
export default connect(mapStateToProps, mapDispatchToProps)(DashNav);

View File

@ -0,0 +1,33 @@
// Libraries
import React, { FunctionComponent } from 'react';
// Components
import { Tooltip } from '@grafana/ui';
interface Props {
icon: string;
tooltip: string;
classSuffix: string;
onClick?: () => void;
href?: string;
}
export const DashNavButton: FunctionComponent<Props> = ({ icon, tooltip, classSuffix, onClick, href }) => {
if (onClick) {
return (
<Tooltip content={tooltip}>
<button className={`btn navbar-button navbar-button--${classSuffix}`} onClick={onClick}>
<i className={icon} />
</button>
</Tooltip>
);
}
return (
<Tooltip content={tooltip}>
<a className={`btn navbar-button navbar-button--${classSuffix}`} href={href}>
<i className={icon} />
</a>
</Tooltip>
);
};

View File

@ -1,119 +0,0 @@
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) {
appEvents.on('save-dashboard', this.saveDashboard.bind(this), $scope);
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);

View File

@ -1 +1,2 @@
export { DashNavCtrl } from './DashNavCtrl';
import DashNav from './DashNav';
export { DashNav };

View File

@ -1,61 +0,0 @@
<div class="navbar">
<div>
<a class="navbar-page-btn" ng-click="ctrl.showSearch()">
<i class="gicon gicon-dashboard"></i>
<span ng-if="ctrl.dashboard.meta.folderId > 0" class="navbar-page-btn--folder">{{ctrl.dashboard.meta.folderTitle}} / </span>{{ctrl.dashboard.title}}
<i class="fa fa-caret-down"></i>
</a>
</div>
<div class="navbar__spacer"></div>
<div class="navbar-buttons navbar-buttons--playlist" ng-if="ctrl.playlistSrv.isPlaying">
<a class="navbar-button navbar-button--tight" ng-click="ctrl.playlistSrv.prev()"><i class="fa fa-step-backward"></i></a>
<a class="navbar-button navbar-button--tight" ng-click="ctrl.playlistSrv.stop()"><i class="fa fa-stop"></i></a>
<a class="navbar-button navbar-button--tight" ng-click="ctrl.playlistSrv.next()"><i class="fa fa-step-forward"></i></a>
</div>
<div class="navbar-buttons navbar-buttons--actions">
<button class="btn navbar-button navbar-button--add-panel" ng-show="::ctrl.dashboard.meta.canSave" bs-tooltip="'Add panel'" data-placement="bottom" ng-click="ctrl.addPanel()">
<i class="gicon gicon-add-panel"></i>
</button>
<button class="btn navbar-button navbar-button--star" ng-show="::ctrl.dashboard.meta.canStar" ng-click="ctrl.starDashboard()" bs-tooltip="'Mark as favorite'" data-placement="bottom">
<i class="fa" ng-class="{'fa-star-o': !ctrl.dashboard.meta.isStarred, 'fa-star': ctrl.dashboard.meta.isStarred}"></i>
</button>
<button class="btn navbar-button navbar-button--share" ng-show="::ctrl.dashboard.meta.canShare" ng-click="ctrl.shareDashboard(0)" bs-tooltip="'Share dashboard'" data-placement="bottom">
<i class="fa fa-share-square-o"></i></a>
</button>
<button class="btn navbar-button navbar-button--save" ng-show="ctrl.dashboard.meta.canSave" ng-click="ctrl.saveDashboard()" bs-tooltip="'Save dashboard <br> CTRL+S'" data-placement="bottom">
<i class="fa fa-save"></i>
</button>
<a class="btn navbar-button navbar-button--snapshot-origin" ng-if="::ctrl.dashboard.snapshot.originalUrl" href="{{ctrl.dashboard.snapshot.originalUrl}}" bs-tooltip="'Open original dashboard'" data-placement="bottom">
<i class="fa fa-link"></i>
</a>
<button class="btn navbar-button navbar-button--settings" ng-click="ctrl.toggleSettings()" bs-tooltip="'Dashboard Settings'" data-placement="bottom" ng-show="ctrl.dashboard.meta.showSettings">
<i class="fa fa-cog"></i>
</button>
</div>
<div class="navbar-buttons navbar-buttons--tv">
<button class="btn navbar-button navbar-button--tv" ng-click="ctrl.toggleViewMode()" bs-tooltip="'Cycle view mode'" data-placement="bottom">
<i class="fa fa-desktop"></i>
</button>
</div>
<gf-time-picker class="gf-timepicker-nav" dashboard="ctrl.dashboard" ng-if="!ctrl.dashboard.timepicker.hidden"></gf-time-picker>
<div class="navbar-buttons navbar-buttons--close">
<button class="btn navbar-button navbar-button--primary" ng-click="ctrl.close()" bs-tooltip="'Back to dashboard'" data-placement="bottom">
<i class="fa fa-reply"></i>
</button>
</div>
</div>
<dashboard-search></dashboard-search>

View File

@ -9,6 +9,7 @@ describe('DashboardRow', () => {
beforeEach(() => {
dashboardMock = {
toggleRow: jest.fn(),
on: jest.fn(),
meta: {
canEdit: true,
},

View File

@ -18,11 +18,11 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
collapsed: this.props.panel.collapsed,
};
appEvents.on('template-variable-value-updated', this.onVariableUpdated);
this.props.dashboard.on('template-variable-value-updated', this.onVariableUpdated);
}
componentWillUnmount() {
appEvents.off('template-variable-value-updated', this.onVariableUpdated);
this.props.dashboard.off('template-variable-value-updated', this.onVariableUpdated);
}
onVariableUpdated = () => {

View File

@ -0,0 +1,36 @@
// Libaries
import React, { PureComponent } from 'react';
// Utils & Services
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
// Types
import { DashboardModel } from '../../state/DashboardModel';
export interface Props {
dashboard: DashboardModel | null;
}
export class DashboardSettings extends PureComponent<Props> {
element: HTMLElement;
angularCmp: AngularComponent;
componentDidMount() {
const loader = getAngularLoader();
const template = '<dashboard-settings dashboard="dashboard" class="dashboard-settings" />';
const scopeProps = { dashboard: this.props.dashboard };
this.angularCmp = loader.load(this.element, scopeProps, template);
}
componentWillUnmount() {
if (this.angularCmp) {
this.angularCmp.destroy();
}
}
render() {
return <div className="panel-height-helper" ref={element => this.element = element} />;
}
}

View File

@ -1 +1,2 @@
export { SettingsCtrl } from './SettingsCtrl';
export { DashboardSettings } from './DashboardSettings';

View File

@ -0,0 +1,36 @@
// Libaries
import React, { PureComponent } from 'react';
// Utils & Services
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
// Types
import { DashboardModel } from '../../state/DashboardModel';
export interface Props {
dashboard: DashboardModel | null;
}
export class SubMenu extends PureComponent<Props> {
element: HTMLElement;
angularCmp: AngularComponent;
componentDidMount() {
const loader = getAngularLoader();
const template = '<dashboard-submenu dashboard="dashboard" />';
const scopeProps = { dashboard: this.props.dashboard };
this.angularCmp = loader.load(this.element, scopeProps, template);
}
componentWillUnmount() {
if (this.angularCmp) {
this.angularCmp.destroy();
}
}
render() {
return <div ref={element => this.element = element} />;
}
}

View File

@ -1 +1,2 @@
export { SubMenuCtrl } from './SubMenuCtrl';
export { SubMenu } from './SubMenu';

View File

@ -7,7 +7,7 @@
<value-select-dropdown ng-if="variable.type !== 'adhoc' && variable.type !== 'textbox'" variable="variable" on-updated="ctrl.variableUpdated(variable)"></value-select-dropdown>
<input type="text" ng-if="variable.type === 'textbox'" ng-model="variable.query" class="gf-form-input width-12" ng-blur="variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ng-keydown="$event.keyCode === 13 && variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ></input>
</div>
<ad-hoc-filters ng-if="variable.type === 'adhoc'" variable="variable"></ad-hoc-filters>
<ad-hoc-filters ng-if="variable.type === 'adhoc'" variable="variable" dashboard="ctrl.dashboard"></ad-hoc-filters>
</div>
<div ng-if="ctrl.dashboard.annotations.list.length > 0">

View File

@ -1,156 +0,0 @@
// Utils
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import coreModule from 'app/core/core_module';
import { removePanel } from 'app/features/dashboard/utils/panel';
// Services
import { AnnotationsSrv } from '../../annotations/annotations_srv';
// Types
import { DashboardModel } from '../state/DashboardModel';
export class DashboardCtrl {
dashboard: DashboardModel;
dashboardViewState: any;
loadedFallbackDashboard: boolean;
editTab: number;
/** @ngInject */
constructor(
private $scope,
private keybindingSrv,
private timeSrv,
private variableSrv,
private dashboardSrv,
private unsavedChangesSrv,
private dashboardViewStateSrv,
private annotationsSrv: AnnotationsSrv,
public playlistSrv
) {
// temp hack due to way dashboards are loaded
// can't use controllerAs on route yet
$scope.ctrl = this;
// TODO: break out settings view to separate view & controller
this.editTab = 0;
// funcs called from React component bindings and needs this binding
this.getPanelContainer = this.getPanelContainer.bind(this);
}
setupDashboard(data) {
try {
this.setupDashboardInternal(data);
} catch (err) {
this.onInitFailed(err, 'Dashboard init failed', true);
}
}
setupDashboardInternal(data) {
const dashboard = this.dashboardSrv.create(data.dashboard, data.meta);
this.dashboardSrv.setCurrent(dashboard);
// init services
this.timeSrv.init(dashboard);
this.annotationsSrv.init(dashboard);
// template values service needs to initialize completely before
// the rest of the dashboard can load
this.variableSrv
.init(dashboard)
// template values failes are non fatal
.catch(this.onInitFailed.bind(this, 'Templating init failed', false))
// continue
.finally(() => {
this.dashboard = dashboard;
this.dashboard.processRepeats();
this.dashboard.updateSubmenuVisibility();
this.dashboard.autoFitPanels(window.innerHeight);
this.unsavedChangesSrv.init(dashboard, this.$scope);
// TODO refactor ViewStateSrv
this.$scope.dashboard = dashboard;
this.dashboardViewState = this.dashboardViewStateSrv.create(this.$scope);
this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard);
this.setWindowTitleAndTheme();
appEvents.emit('dashboard-initialized', dashboard);
})
.catch(this.onInitFailed.bind(this, 'Dashboard init failed', true));
}
onInitFailed(msg, fatal, err) {
console.log(msg, err);
if (err.data && err.data.message) {
err.message = err.data.message;
} else if (!err.message) {
err = { message: err.toString() };
}
this.$scope.appEvent('alert-error', [msg, err.message]);
// protect against recursive fallbacks
if (fatal && !this.loadedFallbackDashboard) {
this.loadedFallbackDashboard = true;
this.setupDashboard({ dashboard: { title: 'Dashboard Init failed' } });
}
}
templateVariableUpdated() {
this.dashboard.processRepeats();
}
setWindowTitleAndTheme() {
window.document.title = config.windowTitlePrefix + this.dashboard.title;
}
showJsonEditor(evt, options) {
const model = {
object: options.object,
updateHandler: options.updateHandler,
};
this.$scope.appEvent('show-dash-editor', {
src: 'public/app/partials/edit_json.html',
model: model,
});
}
getDashboard() {
return this.dashboard;
}
getPanelContainer() {
return this;
}
onRemovingPanel(evt, options) {
options = options || {};
if (!options.panelId) {
return;
}
const panelInfo = this.dashboard.getPanelInfoById(options.panelId);
removePanel(this.dashboard, panelInfo.panel, true);
}
onDestroy() {
if (this.dashboard) {
this.dashboard.destroy();
}
}
init(dashboard) {
this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this));
this.$scope.$on('$destroy', this.onDestroy.bind(this));
this.setupDashboard(dashboard);
}
}
coreModule.controller('DashboardCtrl', DashboardCtrl);

View File

@ -0,0 +1,251 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { DashboardPage, Props, State } from './DashboardPage';
import { DashboardModel } from '../state';
import { cleanUpDashboard } from '../state/actions';
import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock } from 'app/core/redux';
import { DashboardRouteInfo, DashboardInitPhase } from 'app/types';
jest.mock('sass/_variables.scss', () => ({
panelhorizontalpadding: 10,
panelVerticalPadding: 10,
}));
jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({}));
interface ScenarioContext {
cleanUpDashboardMock: NoPayloadActionCreatorMock;
dashboard?: DashboardModel;
setDashboardProp: (overrides?: any, metaOverrides?: any) => void;
wrapper?: ShallowWrapper<Props, State, DashboardPage>;
mount: (propOverrides?: Partial<Props>) => void;
setup?: (fn: () => void) => void;
}
function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel {
const data = Object.assign({
title: 'My dashboard',
panels: [
{
id: 1,
type: 'graph',
title: 'My graph',
gridPos: { x: 0, y: 0, w: 1, h: 1 },
},
],
}, overrides);
const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides);
return new DashboardModel(data, meta);
}
function dashboardPageScenario(description, scenarioFn: (ctx: ScenarioContext) => void) {
describe(description, () => {
let setupFn: () => void;
const ctx: ScenarioContext = {
cleanUpDashboardMock: getNoPayloadActionCreatorMock(cleanUpDashboard),
setup: fn => {
setupFn = fn;
},
setDashboardProp: (overrides?: any, metaOverrides?: any) => {
ctx.dashboard = getTestDashboard(overrides, metaOverrides);
ctx.wrapper.setProps({ dashboard: ctx.dashboard });
},
mount: (propOverrides?: Partial<Props>) => {
const props: Props = {
urlSlug: 'my-dash',
$scope: {},
urlUid: '11',
$injector: {},
routeInfo: DashboardRouteInfo.Normal,
urlEdit: false,
urlFullscreen: false,
initPhase: DashboardInitPhase.NotStarted,
isInitSlow: false,
initDashboard: jest.fn(),
updateLocation: jest.fn(),
notifyApp: jest.fn(),
cleanUpDashboard: ctx.cleanUpDashboardMock,
dashboard: null,
};
Object.assign(props, propOverrides);
ctx.dashboard = props.dashboard;
ctx.wrapper = shallow(<DashboardPage {...props} />);
}
};
beforeEach(() => {
setupFn();
});
scenarioFn(ctx);
});
}
describe('DashboardPage', () => {
dashboardPageScenario("Given initial state", (ctx) => {
ctx.setup(() => {
ctx.mount();
});
it('Should render nothing', () => {
expect(ctx.wrapper).toMatchSnapshot();
});
});
dashboardPageScenario("Dashboard is fetching slowly", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.wrapper.setProps({
isInitSlow: true,
initPhase: DashboardInitPhase.Fetching,
});
});
it('Should render slow init state', () => {
expect(ctx.wrapper).toMatchSnapshot();
});
});
dashboardPageScenario("Dashboard init completed ", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
});
it('Should update title', () => {
expect(document.title).toBe('My dashboard - Grafana');
});
it('Should render dashboard grid', () => {
expect(ctx.wrapper).toMatchSnapshot();
});
});
dashboardPageScenario("When user goes into panel edit", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
ctx.wrapper.setProps({
urlFullscreen: true,
urlEdit: true,
urlPanelId: '1',
});
});
it('Should update model state to fullscreen & edit', () => {
expect(ctx.dashboard.meta.fullscreen).toBe(true);
expect(ctx.dashboard.meta.isEditing).toBe(true);
});
it('Should update component state to fullscreen and edit', () => {
const state = ctx.wrapper.state();
expect(state.isEditing).toBe(true);
expect(state.isFullscreen).toBe(true);
});
});
dashboardPageScenario("When user goes back to dashboard from panel edit", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
ctx.wrapper.setState({ scrollTop: 100 });
ctx.wrapper.setProps({
urlFullscreen: true,
urlEdit: true,
urlPanelId: '1',
});
ctx.wrapper.setProps({
urlFullscreen: false,
urlEdit: false,
urlPanelId: null,
});
});
it('Should update model state normal state', () => {
expect(ctx.dashboard.meta.fullscreen).toBe(false);
expect(ctx.dashboard.meta.isEditing).toBe(false);
});
it('Should update component state to normal and restore scrollTop', () => {
const state = ctx.wrapper.state();
expect(state.isEditing).toBe(false);
expect(state.isFullscreen).toBe(false);
expect(state.scrollTop).toBe(100);
});
});
dashboardPageScenario("When dashboard has editview url state", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
ctx.wrapper.setProps({
editview: 'settings',
});
});
it('should render settings view', () => {
expect(ctx.wrapper).toMatchSnapshot();
});
it('should set animation state', () => {
expect(ctx.wrapper.state().isSettingsOpening).toBe(true);
});
});
dashboardPageScenario("When adding panel", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
ctx.wrapper.setState({ scrollTop: 100 });
ctx.wrapper.instance().onAddPanel();
});
it('should set scrollTop to 0', () => {
expect(ctx.wrapper.state().scrollTop).toBe(0);
});
it('should add panel widget to dashboard panels', () => {
expect(ctx.dashboard.panels[0].type).toBe('add-panel');
});
});
dashboardPageScenario("Given panel with id 0", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp({
panels: [{ id: 0, type: 'graph'}],
schemaVersion: 17,
});
ctx.wrapper.setProps({
urlEdit: true,
urlFullscreen: true,
urlPanelId: '0'
});
});
it('Should go into edit mode' , () => {
expect(ctx.wrapper.state().isEditing).toBe(true);
expect(ctx.wrapper.state().fullscreenPanel.id).toBe(0);
});
});
dashboardPageScenario("When dashboard unmounts", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp({
panels: [{ id: 0, type: 'graph'}],
schemaVersion: 17,
});
ctx.wrapper.unmount();
});
it('Should call clean up action' , () => {
expect(ctx.cleanUpDashboardMock.calls).toBe(1);
});
});
});

View File

@ -0,0 +1,309 @@
// Libraries
import $ from 'jquery';
import React, { PureComponent, MouseEvent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
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';
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 { cleanUpDashboard } from '../state/actions';
import { updateLocation } from 'app/core/actions';
import { notifyApp } from 'app/core/actions';
// Types
import {
StoreState,
DashboardInitPhase,
DashboardRouteInfo,
DashboardInitError,
AppNotificationSeverity,
} from 'app/types';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
export interface Props {
urlUid?: string;
urlSlug?: string;
urlType?: string;
editview?: string;
urlPanelId?: string;
urlFolderId?: string;
$scope: any;
$injector: any;
routeInfo: DashboardRouteInfo;
urlEdit: boolean;
urlFullscreen: boolean;
initPhase: DashboardInitPhase;
isInitSlow: boolean;
dashboard: DashboardModel | null;
initError?: DashboardInitError;
initDashboard: typeof initDashboard;
cleanUpDashboard: typeof cleanUpDashboard;
notifyApp: typeof notifyApp;
updateLocation: typeof updateLocation;
}
export interface State {
isSettingsOpening: boolean;
isEditing: boolean;
isFullscreen: boolean;
fullscreenPanel: PanelModel | null;
scrollTop: number;
rememberScrollTop: number;
showLoadingState: boolean;
}
export class DashboardPage extends PureComponent<Props, State> {
state: State = {
isSettingsOpening: false,
isEditing: false,
isFullscreen: false,
showLoadingState: false,
fullscreenPanel: null,
scrollTop: 0,
rememberScrollTop: 0,
};
async componentDidMount() {
this.props.initDashboard({
$injector: this.props.$injector,
$scope: this.props.$scope,
urlSlug: this.props.urlSlug,
urlUid: this.props.urlUid,
urlType: this.props.urlType,
urlFolderId: this.props.urlFolderId,
routeInfo: this.props.routeInfo,
fixUrl: true,
});
}
componentWillUnmount() {
if (this.props.dashboard) {
this.props.cleanUpDashboard();
}
}
componentDidUpdate(prevProps: Props) {
const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId } = this.props;
if (!dashboard) {
return;
}
// if we just got dashboard update title
if (!prevProps.dashboard) {
document.title = dashboard.title + ' - Grafana';
}
// handle animation states when opening dashboard settings
if (!prevProps.editview && editview) {
this.setState({ isSettingsOpening: true });
setTimeout(() => {
this.setState({ isSettingsOpening: false });
}, 10);
}
// Sync url state with model
if (urlFullscreen !== dashboard.meta.fullscreen || urlEdit !== dashboard.meta.isEditing) {
if (!isNaN(parseInt(urlPanelId, 10))) {
this.onEnterFullscreen();
} else {
this.onLeaveFullscreen();
}
}
}
onEnterFullscreen() {
const { dashboard, urlEdit, urlFullscreen, urlPanelId } = this.props;
const panelId = parseInt(urlPanelId, 10);
// need to expand parent row if this panel is inside a row
dashboard.expandParentRowFor(panelId);
const panel = dashboard.getPanelById(panelId);
if (panel) {
dashboard.setViewMode(panel, urlFullscreen, urlEdit);
this.setState({
isEditing: urlEdit && dashboard.meta.canEdit,
isFullscreen: urlFullscreen,
fullscreenPanel: panel,
rememberScrollTop: this.state.scrollTop,
});
this.setPanelFullscreenClass(urlFullscreen);
} else {
this.handleFullscreenPanelNotFound(urlPanelId);
}
}
onLeaveFullscreen() {
const { dashboard } = this.props;
if (this.state.fullscreenPanel) {
dashboard.setViewMode(this.state.fullscreenPanel, false, false);
}
this.setState(
{
isEditing: false,
isFullscreen: false,
fullscreenPanel: null,
scrollTop: this.state.rememberScrollTop,
},
() => {
dashboard.render();
}
);
this.setPanelFullscreenClass(false);
}
handleFullscreenPanelNotFound(urlPanelId: string) {
// Panel not found
this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`));
// Clear url state
this.props.updateLocation({
query: {
edit: null,
fullscreen: null,
panelId: null,
},
partial: true,
});
}
setPanelFullscreenClass(isFullscreen: boolean) {
$('body').toggleClass('panel-in-fullscreen', isFullscreen);
}
setScrollTop = (e: MouseEvent<HTMLElement>): void => {
const target = e.target as HTMLElement;
this.setState({ scrollTop: target.scrollTop });
};
onAddPanel = () => {
const { dashboard } = this.props;
// Return if the "Add panel" exists already
if (dashboard.panels.length > 0 && dashboard.panels[0].type === 'add-panel') {
return;
}
dashboard.addPanel({
type: 'add-panel',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
title: 'Panel Title',
});
// scroll to top after adding panel
this.setState({ scrollTop: 0 });
};
renderSlowInitState() {
return (
<div className="dashboard-loading">
<div className="dashboard-loading__text">
<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, isInitSlow, initError } = this.props;
const { isSettingsOpening, isEditing, isFullscreen, scrollTop } = this.state;
if (!dashboard) {
if (isInitSlow) {
return this.renderSlowInitState();
}
return null;
}
const classes = classNames({
'dashboard-page--settings-opening': isSettingsOpening,
'dashboard-page--settings-open': !isSettingsOpening && editview,
});
const gridWrapperClasses = classNames({
'dashboard-container': true,
'dashboard-container--has-submenu': dashboard.meta.submenuEnabled,
});
return (
<div className={classes}>
<DashNav
dashboard={dashboard}
isEditing={isEditing}
isFullscreen={isFullscreen}
editview={editview}
$injector={$injector}
onAddPanel={this.onAddPanel}
/>
<div className="scroll-canvas scroll-canvas--dashboard">
<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} />
</div>
</CustomScrollbar>
</div>
</div>
);
}
}
const mapStateToProps = (state: StoreState) => ({
urlUid: state.location.routeParams.uid,
urlSlug: state.location.routeParams.slug,
urlType: state.location.routeParams.type,
editview: state.location.query.editview,
urlPanelId: state.location.query.panelId,
urlFolderId: state.location.query.folderId,
urlFullscreen: state.location.query.fullscreen === true,
urlEdit: state.location.query.edit === true,
initPhase: state.dashboard.initPhase,
isInitSlow: state.dashboard.isInitSlow,
initError: state.dashboard.initError,
dashboard: state.dashboard.model as DashboardModel,
});
const mapDispatchToProps = {
initDashboard,
cleanUpDashboard,
notifyApp,
updateLocation,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DashboardPage));

View File

@ -3,98 +3,84 @@ import React, { Component } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
// Utils & Services
import appEvents from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
import { getBackendSrv } from 'app/core/services/backend_srv';
// Components
import { DashboardPanel } from '../dashgrid/DashboardPanel';
// Redux
import { updateLocation } from 'app/core/actions';
import { initDashboard } from '../state/initDashboard';
// Types
import { StoreState } from 'app/types';
import { StoreState, DashboardRouteInfo } from 'app/types';
import { PanelModel, DashboardModel } from 'app/features/dashboard/state';
interface Props {
panelId: string;
urlPanelId: string;
urlUid?: string;
urlSlug?: string;
urlType?: string;
$scope: any;
$injector: any;
updateLocation: typeof updateLocation;
routeInfo: DashboardRouteInfo;
initDashboard: typeof initDashboard;
dashboard: DashboardModel | null;
}
interface State {
panel: PanelModel | null;
dashboard: DashboardModel | null;
notFound: boolean;
}
export class SoloPanelPage extends Component<Props, State> {
state: State = {
panel: null,
dashboard: null,
notFound: false,
};
componentDidMount() {
const { $injector, $scope, urlUid, urlType, urlSlug } = this.props;
const { $injector, $scope, urlUid, urlType, urlSlug, routeInfo } = this.props;
// handle old urls with no uid
if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) {
this.redirectToNewUrl();
return;
}
const dashboardLoaderSrv = $injector.get('dashboardLoaderSrv');
// subscribe to event to know when dashboard controller is done with inititalization
appEvents.on('dashboard-initialized', this.onDashoardInitialized);
dashboardLoaderSrv.loadDashboard(urlType, urlSlug, urlUid).then(result => {
result.meta.soloMode = true;
$scope.initDashboard(result, $scope);
this.props.initDashboard({
$injector: $injector,
$scope: $scope,
urlSlug: urlSlug,
urlUid: urlUid,
urlType: urlType,
routeInfo: routeInfo,
fixUrl: false,
});
}
redirectToNewUrl() {
getBackendSrv().getDashboardBySlug(this.props.urlSlug).then(res => {
if (res) {
const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
this.props.updateLocation(url);
componentDidUpdate(prevProps: Props) {
const { urlPanelId, dashboard } = this.props;
if (!dashboard) {
return;
}
// we just got the dashboard!
if (!prevProps.dashboard) {
const panelId = parseInt(urlPanelId, 10);
// need to expand parent row if this panel is inside a row
dashboard.expandParentRowFor(panelId);
const panel = dashboard.getPanelById(panelId);
if (!panel) {
this.setState({ notFound: true });
return;
}
});
}
onDashoardInitialized = () => {
const { $scope, panelId } = this.props;
const dashboard: DashboardModel = $scope.dashboard;
const panel = dashboard.getPanelById(parseInt(panelId, 10));
if (!panel) {
this.setState({ notFound: true });
return;
this.setState({ panel });
}
this.setState({ dashboard, panel });
};
}
render() {
const { panelId } = this.props;
const { notFound, panel, dashboard } = this.state;
const { urlPanelId, dashboard } = this.props;
const { notFound, panel } = this.state;
if (notFound) {
return (
<div className="alert alert-error">
Panel with id { panelId } not found
</div>
);
return <div className="alert alert-error">Panel with id {urlPanelId} not found</div>;
}
if (!panel) {
@ -113,11 +99,12 @@ const mapStateToProps = (state: StoreState) => ({
urlUid: state.location.routeParams.uid,
urlSlug: state.location.routeParams.slug,
urlType: state.location.routeParams.type,
panelId: state.location.query.panelId
urlPanelId: state.location.query.panelId,
dashboard: state.dashboard.model as DashboardModel,
});
const mapDispatchToProps = {
updateLocation
initDashboard,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(SoloPanelPage));

View File

@ -0,0 +1,546 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`] = `
<div
className=""
>
<Connect(DashNav)
$injector={Object {}}
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
isEditing={false}
isFullscreen={false}
onAddPanel={[Function]}
/>
<div
className="scroll-canvas scroll-canvas--dashboard"
>
<CustomScrollbar
autoHeightMax="100%"
autoHeightMin="100%"
autoHide={false}
autoHideDuration={200}
autoHideTimeout={200}
customClassName="custom-scrollbars"
hideTracksWhenNotNeeded={false}
scrollTop={0}
setScrollTop={[Function]}
>
<div
className="dashboard-container"
>
<DashboardGrid
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
isEditing={false}
isFullscreen={false}
/>
</div>
</CustomScrollbar>
</div>
</div>
`;
exports[`DashboardPage Dashboard is fetching slowly Should render slow init state 1`] = `
<div
className="dashboard-loading"
>
<div
className="dashboard-loading__text"
>
<i
className="fa fa-spinner fa-spin"
/>
Fetching
</div>
</div>
`;
exports[`DashboardPage Given initial state Should render nothing 1`] = `""`;
exports[`DashboardPage When dashboard has editview url state should render settings view 1`] = `
<div
className="dashboard-page--settings-opening"
>
<Connect(DashNav)
$injector={Object {}}
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
editview="settings"
isEditing={false}
isFullscreen={false}
onAddPanel={[Function]}
/>
<div
className="scroll-canvas scroll-canvas--dashboard"
>
<CustomScrollbar
autoHeightMax="100%"
autoHeightMin="100%"
autoHide={false}
autoHideDuration={200}
autoHideTimeout={200}
customClassName="custom-scrollbars"
hideTracksWhenNotNeeded={false}
scrollTop={0}
setScrollTop={[Function]}
>
<DashboardSettings
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
/>
<div
className="dashboard-container"
>
<DashboardGrid
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
isEditing={false}
isFullscreen={false}
/>
</div>
</CustomScrollbar>
</div>
</div>
`;

View File

@ -1,11 +1,14 @@
import React from 'react';
// Libaries
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import ReactGridLayout, { ItemCallback } from 'react-grid-layout';
import classNames from 'classnames';
import sizeMe from 'react-sizeme';
// Types
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { DashboardPanel } from './DashboardPanel';
import { DashboardModel, PanelModel } from '../state';
import classNames from 'classnames';
import sizeMe from 'react-sizeme';
let lastGridWidth = 1200;
let ignoreNextWidthChange = false;
@ -76,19 +79,18 @@ function GridWrapper({
const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper);
export interface DashboardGridProps {
export interface Props {
dashboard: DashboardModel;
isEditing: boolean;
isFullscreen: boolean;
}
export class DashboardGrid extends React.Component<DashboardGridProps> {
export class DashboardGrid extends PureComponent<Props> {
gridToPanelMap: any;
panelMap: { [id: string]: PanelModel };
constructor(props: DashboardGridProps) {
super(props);
// subscribe to dashboard events
const dashboard = this.props.dashboard;
componentDidMount() {
const { dashboard } = this.props;
dashboard.on('panel-added', this.triggerForceUpdate);
dashboard.on('panel-removed', this.triggerForceUpdate);
dashboard.on('repeats-processed', this.triggerForceUpdate);
@ -97,6 +99,16 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
dashboard.on('row-expanded', this.triggerForceUpdate);
}
componentWillUnmount() {
const { dashboard } = this.props;
dashboard.off('panel-added', this.triggerForceUpdate);
dashboard.off('panel-removed', this.triggerForceUpdate);
dashboard.off('repeats-processed', this.triggerForceUpdate);
dashboard.off('view-mode-changed', this.onViewModeChanged);
dashboard.off('row-collapsed', this.triggerForceUpdate);
dashboard.off('row-expanded', this.triggerForceUpdate);
}
buildLayout() {
const layout = [];
this.panelMap = {};
@ -151,7 +163,6 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
onViewModeChanged = () => {
ignoreNextWidthChange = true;
this.forceUpdate();
}
updateGridPos = (item: ReactGridLayout.Layout, layout: ReactGridLayout.Layout[]) => {
@ -197,18 +208,20 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
}
render() {
const { dashboard, isFullscreen } = this.props;
return (
<SizedReactLayoutGrid
className={classNames({ layout: true })}
layout={this.buildLayout()}
isResizable={this.props.dashboard.meta.canEdit}
isDraggable={this.props.dashboard.meta.canEdit}
isResizable={dashboard.meta.canEdit}
isDraggable={dashboard.meta.canEdit}
onLayoutChange={this.onLayoutChange}
onWidthChange={this.onWidthChange}
onDragStop={this.onDragStop}
onResize={this.onResize}
onResizeStop={this.onResizeStop}
isFullscreen={this.props.dashboard.meta.fullscreen}
isFullscreen={isFullscreen}
>
{this.renderPanels()}
</SizedReactLayoutGrid>

View File

@ -1,8 +1,6 @@
import './containers/DashboardCtrl';
import './dashgrid/DashboardGridDirective';
// Services
import './services/DashboardViewStateSrv';
import './services/UnsavedChangesSrv';
import './services/DashboardLoaderSrv';
import './services/DashboardSrv';

View File

@ -1,25 +1,74 @@
import coreModule from 'app/core/core_module';
import { DashboardModel } from '../state/DashboardModel';
import { appEvents } from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
import { DashboardModel } from '../state/DashboardModel';
import { removePanel } from '../utils/panel';
export class DashboardSrv {
dash: any;
dashboard: DashboardModel;
/** @ngInject */
constructor(private backendSrv, private $rootScope, private $location) {}
constructor(private backendSrv, private $rootScope, private $location) {
appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope);
appEvents.on('panel-change-view', this.onPanelChangeView);
appEvents.on('remove-panel', this.onRemovePanel);
}
create(dashboard, meta) {
return new DashboardModel(dashboard, meta);
}
setCurrent(dashboard) {
this.dash = dashboard;
setCurrent(dashboard: DashboardModel) {
this.dashboard = dashboard;
}
getCurrent() {
return this.dash;
getCurrent(): DashboardModel {
return this.dashboard;
}
onRemovePanel = (panelId: number) => {
const dashboard = this.getCurrent();
removePanel(dashboard, dashboard.getPanelById(panelId), true);
};
onPanelChangeView = (options) => {
const urlParams = this.$location.search();
// handle toggle logic
if (options.fullscreen === urlParams.fullscreen) {
// I hate using these truthy converters (!!) but in this case
// I think it's appropriate. edit can be null/false/undefined and
// here i want all of those to compare the same
if (!!options.edit === !!urlParams.edit) {
delete urlParams.fullscreen;
delete urlParams.edit;
delete urlParams.panelId;
this.$location.search(urlParams);
return;
}
}
if (options.fullscreen) {
urlParams.fullscreen = true;
} else {
delete urlParams.fullscreen;
}
if (options.edit) {
urlParams.edit = true;
} else {
delete urlParams.edit;
}
if (options.panelId || options.panelId === 0) {
urlParams.panelId = options.panelId;
} else {
delete urlParams.panelId;
}
this.$location.search(urlParams);
};
handleSaveDashboardError(clone, options, err) {
options = options || {};
options.overwrite = true;
@ -75,10 +124,10 @@ export class DashboardSrv {
}
postSave(clone, data) {
this.dash.version = data.version;
this.dashboard.version = data.version;
// important that these happens before location redirect below
this.$rootScope.appEvent('dashboard-saved', this.dash);
this.$rootScope.appEvent('dashboard-saved', this.dashboard);
this.$rootScope.appEvent('alert-success', ['Dashboard saved']);
const newUrl = locationUtil.stripBaseFromUrl(data.url);
@ -88,12 +137,12 @@ export class DashboardSrv {
this.$location.url(newUrl).replace();
}
return this.dash;
return this.dashboard;
}
save(clone, options) {
options = options || {};
options.folderId = options.folderId >= 0 ? options.folderId : this.dash.meta.folderId || clone.folderId;
options.folderId = options.folderId >= 0 ? options.folderId : this.dashboard.meta.folderId || clone.folderId;
return this.backendSrv
.saveDashboard(clone, options)
@ -103,26 +152,26 @@ export class DashboardSrv {
saveDashboard(options?, clone?) {
if (clone) {
this.setCurrent(this.create(clone, this.dash.meta));
this.setCurrent(this.create(clone, this.dashboard.meta));
}
if (this.dash.meta.provisioned) {
if (this.dashboard.meta.provisioned) {
return this.showDashboardProvisionedModal();
}
if (!this.dash.meta.canSave && options.makeEditable !== true) {
if (!this.dashboard.meta.canSave && options.makeEditable !== true) {
return Promise.resolve();
}
if (this.dash.title === 'New dashboard') {
if (this.dashboard.title === 'New dashboard') {
return this.showSaveAsModal();
}
if (this.dash.version > 0) {
if (this.dashboard.version > 0) {
return this.showSaveModal();
}
return this.save(this.dash.getSaveModelClone(), options);
return this.save(this.dashboard.getSaveModelClone(), options);
}
saveJSONDashboard(json: string) {
@ -163,8 +212,8 @@ export class DashboardSrv {
}
return promise.then(res => {
if (this.dash && this.dash.id === dashboardId) {
this.dash.meta.isStarred = res;
if (this.dashboard && this.dashboard.id === dashboardId) {
this.dashboard.meta.isStarred = res;
}
return res;
});

View File

@ -1,64 +0,0 @@
import config from 'app/core/config';
import { DashboardViewStateSrv } from './DashboardViewStateSrv';
import { DashboardModel } from '../state/DashboardModel';
describe('when updating view state', () => {
const location = {
replace: jest.fn(),
search: jest.fn(),
};
const $scope = {
appEvent: jest.fn(),
onAppEvent: jest.fn(() => {}),
dashboard: new DashboardModel({
panels: [{ id: 1 }],
}),
};
let viewState;
beforeEach(() => {
config.bootData = {
user: {
orgId: 1,
},
};
});
describe('to fullscreen true and edit true', () => {
beforeEach(() => {
location.search = jest.fn(() => {
return { fullscreen: true, edit: true, panelId: 1 };
});
viewState = new DashboardViewStateSrv($scope, location, {});
});
it('should update querystring and view state', () => {
const updateState = { fullscreen: true, edit: true, panelId: 1 };
viewState.update(updateState);
expect(location.search).toHaveBeenCalledWith({
edit: true,
editview: null,
fullscreen: true,
orgId: 1,
panelId: 1,
});
expect(viewState.dashboard.meta.fullscreen).toBe(true);
expect(viewState.state.fullscreen).toBe(true);
});
});
describe('to fullscreen false', () => {
beforeEach(() => {
viewState = new DashboardViewStateSrv($scope, location, {});
});
it('should remove params from query string', () => {
viewState.update({ fullscreen: true, panelId: 1, edit: true });
viewState.update({ fullscreen: false });
expect(viewState.state.fullscreen).toBe(null);
});
});
});

View File

@ -1,185 +0,0 @@
import angular from 'angular';
import _ from 'lodash';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import { DashboardModel } from '../state/DashboardModel';
// represents the transient view state
// like fullscreen panel & edit
export class DashboardViewStateSrv {
state: any;
panelScopes: any;
$scope: any;
dashboard: DashboardModel;
fullscreenPanel: any;
oldTimeRange: any;
/** @ngInject */
constructor($scope, private $location, private $timeout) {
const self = this;
self.state = {};
self.panelScopes = [];
self.$scope = $scope;
self.dashboard = $scope.dashboard;
$scope.onAppEvent('$routeUpdate', () => {
const urlState = self.getQueryStringState();
if (self.needsSync(urlState)) {
self.update(urlState, true);
}
});
$scope.onAppEvent('panel-change-view', (evt, payload) => {
self.update(payload);
});
// this marks changes to location during this digest cycle as not to add history item
// don't want url changes like adding orgId to add browser history
$location.replace();
this.update(this.getQueryStringState());
}
needsSync(urlState) {
return _.isEqual(this.state, urlState) === false;
}
getQueryStringState() {
const state = this.$location.search();
state.panelId = parseInt(state.panelId, 10) || null;
state.fullscreen = state.fullscreen ? true : null;
state.edit = state.edit === 'true' || state.edit === true || null;
state.editview = state.editview || null;
state.orgId = config.bootData.user.orgId;
return state;
}
serializeToUrl() {
const urlState = _.clone(this.state);
urlState.fullscreen = this.state.fullscreen ? true : null;
urlState.edit = this.state.edit ? true : null;
return urlState;
}
update(state, fromRouteUpdated?) {
// implement toggle logic
if (state.toggle) {
delete state.toggle;
if (this.state.fullscreen && state.fullscreen) {
if (this.state.edit === state.edit) {
state.fullscreen = !state.fullscreen;
}
}
}
_.extend(this.state, state);
if (!this.state.fullscreen) {
this.state.fullscreen = null;
this.state.edit = null;
// clear panel id unless in solo mode
if (!this.dashboard.meta.soloMode) {
this.state.panelId = null;
}
}
if ((this.state.fullscreen || this.dashboard.meta.soloMode) && this.state.panelId) {
// Trying to render panel in fullscreen when it's in the collapsed row causes an issue.
// So in this case expand collapsed row first.
this.toggleCollapsedPanelRow(this.state.panelId);
}
// if no edit state cleanup tab parm
if (!this.state.edit) {
delete this.state.tab;
}
// do not update url params if we are here
// from routeUpdated event
if (fromRouteUpdated !== true) {
this.$location.search(this.serializeToUrl());
}
this.syncState();
}
toggleCollapsedPanelRow(panelId) {
for (const panel of this.dashboard.panels) {
if (panel.collapsed) {
for (const rowPanel of panel.panels) {
if (rowPanel.id === panelId) {
this.dashboard.toggleRow(panel);
return;
}
}
}
}
}
syncState() {
if (this.state.fullscreen) {
const panel = this.dashboard.getPanelById(this.state.panelId);
if (!panel) {
this.state.fullscreen = null;
this.state.panelId = null;
this.state.edit = null;
this.update(this.state);
setTimeout(() => {
appEvents.emit('alert-error', ['Error', 'Panel not found']);
}, 100);
return;
}
if (!panel.fullscreen) {
this.enterFullscreen(panel);
} else if (this.dashboard.meta.isEditing !== this.state.edit) {
this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit);
}
} else if (this.fullscreenPanel) {
this.leaveFullscreen();
}
}
leaveFullscreen() {
const panel = this.fullscreenPanel;
this.dashboard.setViewMode(panel, false, false);
delete this.fullscreenPanel;
this.$timeout(() => {
appEvents.emit('dash-scroll', { restore: true });
if (this.oldTimeRange !== this.dashboard.time) {
this.dashboard.startRefresh();
} else {
this.dashboard.render();
}
});
}
enterFullscreen(panel) {
const isEditing = this.state.edit && this.dashboard.meta.canEdit;
this.oldTimeRange = this.dashboard.time;
this.fullscreenPanel = panel;
// Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode()
this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 });
this.dashboard.setViewMode(panel, true, isEditing);
}
}
/** @ngInject */
export function dashboardViewStateSrv($location, $timeout) {
return {
create: $scope => {
return new DashboardViewStateSrv($scope, $location, $timeout);
},
};
}
angular.module('grafana.services').factory('dashboardViewStateSrv', dashboardViewStateSrv);

View File

@ -1,20 +1,26 @@
// Libaries
import moment from 'moment';
import _ from 'lodash';
import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
// Constants
import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
import { GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
// Utils & Services
import { Emitter } from 'app/core/utils/emitter';
import { contextSrv } from 'app/core/services/context_srv';
import sortByKeys from 'app/core/utils/sort_by_keys';
// Types
import { PanelModel } from './PanelModel';
import { DashboardMigrator } from './DashboardMigrator';
import { TimeRange } from '@grafana/ui/src';
import { UrlQueryValue, KIOSK_MODE_TV, DashboardMeta } from 'app/types';
export class DashboardModel {
id: any;
uid: any;
title: any;
uid: string;
title: string;
autoUpdate: any;
description: any;
tags: any;
@ -43,7 +49,7 @@ export class DashboardModel {
// repeat process cycles
iteration: number;
meta: any;
meta: DashboardMeta;
events: Emitter;
static nonPersistedProperties: { [str: string]: boolean } = {
@ -127,6 +133,8 @@ export class DashboardModel {
meta.canEdit = meta.canEdit !== false;
meta.showSettings = meta.canEdit;
meta.canMakeEditable = meta.canSave && !this.editable;
meta.fullscreen = false;
meta.isEditing = false;
if (!this.editable) {
meta.canEdit = false;
@ -860,11 +868,7 @@ export class DashboardModel {
return !_.isEqual(updated, this.originalTemplating);
}
autoFitPanels(viewHeight: number) {
if (!this.meta.autofitpanels) {
return;
}
autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) {
const currentGridHeight = Math.max(
...this.panels.map(panel => {
return panel.gridPos.h + panel.gridPos.y;
@ -878,12 +882,12 @@ export class DashboardModel {
let visibleHeight = viewHeight - navbarHeight - margin;
// Remove submenu height if visible
if (this.meta.submenuEnabled && !this.meta.kiosk) {
if (this.meta.submenuEnabled && !kioskMode) {
visibleHeight -= submenuHeight;
}
// add back navbar height
if (this.meta.kiosk === 'b') {
if (kioskMode === KIOSK_MODE_TV) {
visibleHeight += 55;
}
@ -895,4 +899,23 @@ export class DashboardModel {
panel.gridPos.h = Math.round(panel.gridPos.h / scaleFactor) || 1;
});
}
templateVariableValueUpdated() {
this.processRepeats();
this.events.emit('template-variable-value-updated');
}
expandParentRowFor(panelId: number) {
for (const panel of this.panels) {
if (panel.collapsed) {
for (const rowPanel of panel.panels) {
if (rowPanel.id === panelId) {
this.toggleRow(panel);
return;
}
}
}
}
}
}

View File

@ -1,39 +1,43 @@
import { StoreState } from 'app/types';
import { ThunkAction } from 'redux-thunk';
// Services & Utils
import { getBackendSrv } from 'app/core/services/backend_srv';
import appEvents from 'app/core/app_events';
import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux';
import { createSuccessNotification } from 'app/core/copy/appNotification';
// Actions
import { loadPluginDashboards } from '../../plugins/state/actions';
import { notifyApp } from 'app/core/actions';
// Types
import {
ThunkResult,
DashboardAcl,
DashboardAclDTO,
PermissionLevel,
DashboardAclUpdateDTO,
NewDashboardAclItem,
} from 'app/types/acl';
MutableDashboard,
DashboardInitError,
} from 'app/types';
export enum ActionTypes {
LoadDashboardPermissions = 'LOAD_DASHBOARD_PERMISSIONS',
LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS',
}
export const loadDashboardPermissions = actionCreatorFactory<DashboardAclDTO[]>('LOAD_DASHBOARD_PERMISSIONS').create();
export interface LoadDashboardPermissionsAction {
type: ActionTypes.LoadDashboardPermissions;
payload: DashboardAcl[];
}
export const dashboardInitFetching = noPayloadActionCreatorFactory('DASHBOARD_INIT_FETCHING').create();
export interface LoadStarredDashboardsAction {
type: ActionTypes.LoadStarredDashboards;
payload: DashboardAcl[];
}
export const dashboardInitServices = noPayloadActionCreatorFactory('DASHBOARD_INIT_SERVICES').create();
export type Action = LoadDashboardPermissionsAction | LoadStarredDashboardsAction;
export const dashboardInitSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_INIT_SLOW').create();
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, any>;
export const dashboardInitCompleted = actionCreatorFactory<MutableDashboard>('DASHBOARD_INIT_COMLETED').create();
export const loadDashboardPermissions = (items: DashboardAclDTO[]): LoadDashboardPermissionsAction => ({
type: ActionTypes.LoadDashboardPermissions,
payload: items,
});
/*
* 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 => {
@ -124,7 +128,7 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar
export function importDashboard(data, dashboardTitle: string): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().post('/api/dashboards/import', data);
appEvents.emit('alert-success', ['Dashboard Imported', dashboardTitle]);
dispatch(notifyApp(createSuccessNotification('Dashboard Imported', dashboardTitle)));
dispatch(loadPluginDashboards());
};
}
@ -135,3 +139,4 @@ export function removeDashboard(uri: string): ThunkResult<void> {
dispatch(loadPluginDashboards());
};
}

View File

@ -0,0 +1,152 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { initDashboard, InitDashboardArgs } from './initDashboard';
import { DashboardRouteInfo } from 'app/types';
import { getBackendSrv } from 'app/core/services/backend_srv';
import {
dashboardInitFetching,
dashboardInitCompleted,
dashboardInitServices,
} from './actions';
jest.mock('app/core/services/backend_srv');
const mockStore = configureMockStore([thunk]);
interface ScenarioContext {
args: InitDashboardArgs;
timeSrv: any;
annotationsSrv: any;
unsavedChangesSrv: any;
variableSrv: any;
dashboardSrv: any;
keybindingSrv: any;
backendSrv: any;
setup: (fn: () => void) => void;
actions: any[];
storeState: any;
}
type ScenarioFn = (ctx: ScenarioContext) => void;
function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
describe(description, () => {
const timeSrv = { init: jest.fn() };
const annotationsSrv = { init: jest.fn() };
const unsavedChangesSrv = { init: jest.fn() };
const variableSrv = { init: jest.fn() };
const dashboardSrv = { setCurrent: jest.fn() };
const keybindingSrv = { setupDashboardBindings: jest.fn() };
const injectorMock = {
get: (name: string) => {
switch (name) {
case 'timeSrv':
return timeSrv;
case 'annotationsSrv':
return annotationsSrv;
case 'unsavedChangesSrv':
return unsavedChangesSrv;
case 'dashboardSrv':
return dashboardSrv;
case 'variableSrv':
return variableSrv;
case 'keybindingSrv':
return keybindingSrv;
default:
throw { message: 'Unknown service ' + name };
}
},
};
let setupFn = () => {};
const ctx: ScenarioContext = {
args: {
$injector: injectorMock,
$scope: {},
fixUrl: false,
routeInfo: DashboardRouteInfo.Normal,
},
backendSrv: getBackendSrv(),
timeSrv,
annotationsSrv,
unsavedChangesSrv,
variableSrv,
dashboardSrv,
keybindingSrv,
actions: [],
storeState: {
location: {
query: {},
},
user: {},
},
setup: (fn: () => void) => {
setupFn = fn;
},
};
beforeEach(async () => {
setupFn();
const store = mockStore(ctx.storeState);
await store.dispatch(initDashboard(ctx.args));
ctx.actions = store.getActions();
});
scenarioFn(ctx);
});
}
describeInitScenario('Initializing new dashboard', ctx => {
ctx.setup(() => {
ctx.storeState.user.orgId = 12;
ctx.args.routeInfo = DashboardRouteInfo.New;
});
it('Should send action dashboardInitFetching', () => {
expect(ctx.actions[0].type).toBe(dashboardInitFetching.type);
});
it('Should send action dashboardInitServices ', () => {
expect(ctx.actions[1].type).toBe(dashboardInitServices.type);
});
it('Should update location with orgId query param', () => {
expect(ctx.actions[2].type).toBe('UPDATE_LOCATION');
expect(ctx.actions[2].payload.query.orgId).toBe(12);
});
it('Should send action dashboardInitCompleted', () => {
expect(ctx.actions[3].type).toBe(dashboardInitCompleted.type);
expect(ctx.actions[3].payload.title).toBe('New dashboard');
});
it('Should Initializing services', () => {
expect(ctx.timeSrv.init).toBeCalled();
expect(ctx.annotationsSrv.init).toBeCalled();
expect(ctx.variableSrv.init).toBeCalled();
expect(ctx.unsavedChangesSrv.init).toBeCalled();
expect(ctx.keybindingSrv.setupDashboardBindings).toBeCalled();
expect(ctx.dashboardSrv.setCurrent).toBeCalled();
});
});
describeInitScenario('Initializing home dashboard', ctx => {
ctx.setup(() => {
ctx.args.routeInfo = DashboardRouteInfo.Home;
ctx.backendSrv.get.mockReturnValue(Promise.resolve({
redirectUri: '/u/123/my-home'
}));
});
it('Should redirect to custom home dashboard', () => {
expect(ctx.actions[1].type).toBe('UPDATE_LOCATION');
expect(ctx.actions[1].payload.path).toBe('/u/123/my-home');
});
});

View File

@ -0,0 +1,233 @@
// Services & Utils
import { createErrorNotification } from 'app/core/copy/appNotification';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { DashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { AnnotationsSrv } from 'app/features/annotations/annotations_srv';
import { VariableSrv } from 'app/features/templating/variable_srv';
import { KeybindingSrv } from 'app/core/services/keybindingSrv';
// Actions
import { updateLocation } from 'app/core/actions';
import { notifyApp } from 'app/core/actions';
import locationUtil from 'app/core/utils/location_util';
import {
dashboardInitFetching,
dashboardInitCompleted,
dashboardInitFailed,
dashboardInitSlow,
dashboardInitServices,
} from './actions';
// Types
import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO } from 'app/types';
import { DashboardModel } from './DashboardModel';
export interface InitDashboardArgs {
$injector: any;
$scope: any;
urlUid?: string;
urlSlug?: string;
urlType?: string;
urlFolderId?: string;
routeInfo: DashboardRouteInfo;
fixUrl: boolean;
}
async function redirectToNewUrl(slug: string, dispatch: ThunkDispatch, currentPath: string) {
const res = await getBackendSrv().getDashboardBySlug(slug);
if (res) {
let newUrl = res.meta.url;
// fix solo route urls
if (currentPath.indexOf('dashboard-solo') !== -1) {
newUrl = newUrl.replace('/d/', '/d-solo/');
}
const url = locationUtil.stripBaseFromUrl(newUrl);
dispatch(updateLocation({ path: url, partial: true, replace: true }));
}
}
async function fetchDashboard(
args: InitDashboardArgs,
dispatch: ThunkDispatch,
getState: () => StoreState
): Promise<DashboardDTO | null> {
try {
switch (args.routeInfo) {
case DashboardRouteInfo.Home: {
// load home dash
const dashDTO: DashboardDTO = await getBackendSrv().get('/api/dashboards/home');
// if user specified a custom home dashboard redirect to that
if (dashDTO.redirectUri) {
const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri);
dispatch(updateLocation({ path: newUrl, replace: true }));
return null;
}
// disable some actions on the default home dashboard
dashDTO.meta.canSave = false;
dashDTO.meta.canShare = false;
dashDTO.meta.canStar = false;
return dashDTO;
}
case DashboardRouteInfo.Normal: {
// for old db routes we redirect
if (args.urlType === 'db') {
redirectToNewUrl(args.urlSlug, dispatch, getState().location.path);
return null;
}
const loaderSrv: DashboardLoaderSrv = args.$injector.get('dashboardLoaderSrv');
const dashDTO: DashboardDTO = await loaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid);
if (args.fixUrl && dashDTO.meta.url) {
// check if the current url is correct (might be old slug)
const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url);
const currentPath = getState().location.path;
if (dashboardUrl !== currentPath) {
// replace url to not create additional history items and then return so that initDashboard below isn't executed multiple times.
dispatch(updateLocation({ path: dashboardUrl, partial: true, replace: true }));
return null;
}
}
return dashDTO;
}
case DashboardRouteInfo.New: {
return getNewDashboardModelData(args.urlFolderId);
}
default:
throw { message: 'Unknown route ' + args.routeInfo };
}
} catch (err) {
dispatch(dashboardInitFailed({ message: 'Failed to fetch dashboard', error: err }));
console.log(err);
return null;
}
}
/**
* This action (or saga) does everything needed to bootstrap a dashboard & dashboard model.
* First it handles the process of fetching the dashboard, correcting the url if required (causing redirects/url updates)
*
* This is used both for single dashboard & solo panel routes, home & new dashboard routes.
*
* Then it handles the initializing of the old angular services that the dashboard components & panels still depend on
*
*/
export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
return async (dispatch, getState) => {
// set fetching state
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(dashboardInitSlow());
}
}, 500);
// fetch dashboard data
const dashDTO = await fetchDashboard(args, dispatch, getState);
// returns null if there was a redirect or error
if (!dashDTO) {
return;
}
// set initializing state
dispatch(dashboardInitServices());
// create model
let dashboard: DashboardModel;
try {
dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta);
} catch (err) {
dispatch(dashboardInitFailed({ message: 'Failed create dashboard model', error: err }));
console.log(err);
return;
}
// add missing orgId query param
const storeState = getState();
if (!storeState.location.query.orgId) {
dispatch(updateLocation({ query: { orgId: storeState.user.orgId }, partial: true, replace: true }));
}
// init services
const timeSrv: TimeSrv = args.$injector.get('timeSrv');
const annotationsSrv: AnnotationsSrv = args.$injector.get('annotationsSrv');
const variableSrv: VariableSrv = args.$injector.get('variableSrv');
const keybindingSrv: KeybindingSrv = args.$injector.get('keybindingSrv');
const unsavedChangesSrv = args.$injector.get('unsavedChangesSrv');
const dashboardSrv: DashboardSrv = args.$injector.get('dashboardSrv');
timeSrv.init(dashboard);
annotationsSrv.init(dashboard);
// template values service needs to initialize completely before
// the rest of the dashboard can load
try {
await variableSrv.init(dashboard);
} catch (err) {
dispatch(notifyApp(createErrorNotification('Templating init failed', err)));
console.log(err);
}
try {
dashboard.processRepeats();
dashboard.updateSubmenuVisibility();
// handle auto fix experimental feature
const queryParams = getState().location.query;
if (queryParams.autofitpanels) {
dashboard.autoFitPanels(window.innerHeight, queryParams.kiosk);
}
// init unsaved changes tracking
unsavedChangesSrv.init(dashboard, args.$scope);
keybindingSrv.setupDashboardBindings(args.$scope, dashboard);
} catch (err) {
dispatch(notifyApp(createErrorNotification('Dashboard init failed', err)));
console.log(err);
}
// legacy srv state
dashboardSrv.setCurrent(dashboard);
// yay we are done
dispatch(dashboardInitCompleted(dashboard));
};
}
function getNewDashboardModelData(urlFolderId?: string): any {
const data = {
meta: {
canStar: false,
canShare: false,
isNew: true,
folderId: 0,
},
dashboard: {
title: 'New dashboard',
panels: [
{
type: 'add-panel',
gridPos: { x: 0, y: 0, w: 12, h: 9 },
title: 'Panel Title',
},
],
},
};
if (urlFolderId) {
data.meta.folderId = parseInt(urlFolderId, 10);
}
return data;
}

View File

@ -1,19 +1,23 @@
import { Action, ActionTypes } from './actions';
import { OrgRole, PermissionLevel, DashboardState } from 'app/types';
import {
loadDashboardPermissions,
dashboardInitFetching,
dashboardInitCompleted,
dashboardInitFailed,
dashboardInitSlow,
} from './actions';
import { OrgRole, PermissionLevel, DashboardState, DashboardInitPhase } from 'app/types';
import { initialState, dashboardReducer } from './reducers';
import { DashboardModel } from './DashboardModel';
describe('dashboard reducer', () => {
describe('loadDashboardPermissions', () => {
let state: DashboardState;
beforeEach(() => {
const action: Action = {
type: ActionTypes.LoadDashboardPermissions,
payload: [
{ id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View },
{ id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit },
],
};
const action = loadDashboardPermissions([
{ id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View },
{ id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit },
]);
state = dashboardReducer(initialState, action);
});
@ -21,4 +25,47 @@ describe('dashboard reducer', () => {
expect(state.permissions.length).toBe(2);
});
});
describe('dashboardInitCompleted', () => {
let state: DashboardState;
beforeEach(() => {
state = dashboardReducer(initialState, dashboardInitFetching());
state = dashboardReducer(state, dashboardInitSlow());
state = dashboardReducer(state, dashboardInitCompleted(new DashboardModel({ title: 'My dashboard' })));
});
it('should set model', async () => {
expect(state.model.title).toBe('My dashboard');
});
it('should set reset isInitSlow', async () => {
expect(state.isInitSlow).toBe(false);
});
});
describe('dashboardInitFailed', () => {
let state: DashboardState;
beforeEach(() => {
state = dashboardReducer(initialState, dashboardInitFetching());
state = dashboardReducer(state, dashboardInitFailed({message: 'Oh no', error: 'sad'}));
});
it('should set model', async () => {
expect(state.model.title).toBe('Dashboard init failed');
});
it('should set reset isInitSlow', async () => {
expect(state.isInitSlow).toBe(false);
});
it('should set initError', async () => {
expect(state.initError.message).toBe('Oh no');
});
it('should set phase failed', async () => {
expect(state.initPhase).toBe(DashboardInitPhase.Failed);
});
});
});

View File

@ -1,21 +1,90 @@
import { DashboardState } from 'app/types';
import { Action, ActionTypes } 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 = {
initPhase: DashboardInitPhase.NotStarted,
isInitSlow: false,
model: null,
permissions: [],
};
export const dashboardReducer = (state = initialState, action: Action): DashboardState => {
switch (action.type) {
case ActionTypes.LoadDashboardPermissions:
export const dashboardReducer = reducerFactory(initialState)
.addMapper({
filter: loadDashboardPermissions,
mapper: (state, action) => ({
...state,
permissions: processAclItems(action.payload),
}),
})
.addMapper({
filter: dashboardInitFetching,
mapper: state => ({
...state,
initPhase: DashboardInitPhase.Fetching,
}),
})
.addMapper({
filter: dashboardInitServices,
mapper: state => ({
...state,
initPhase: DashboardInitPhase.Services,
}),
})
.addMapper({
filter: dashboardInitSlow,
mapper: state => ({
...state,
isInitSlow: true,
}),
})
.addMapper({
filter: dashboardInitFailed,
mapper: (state, action) => ({
...state,
initPhase: DashboardInitPhase.Failed,
isInitSlow: false,
initError: action.payload,
model: new DashboardModel({ title: 'Dashboard init failed' }, { canSave: false, canEdit: false }),
}),
})
.addMapper({
filter: dashboardInitCompleted,
mapper: (state, action) => ({
...state,
initPhase: DashboardInitPhase.Completed,
model: action.payload,
isInitSlow: false,
}),
})
.addMapper({
filter: cleanUpDashboard,
mapper: (state, action) => {
// Destroy current DashboardModel
// Very important as this removes all dashboard event listeners
state.model.destroy();
return {
...state,
permissions: processAclItems(action.payload),
initPhase: DashboardInitPhase.NotStarted,
model: null,
isInitSlow: false,
initError: null,
};
}
return state;
};
},
})
.create();
export default {
dashboard: dashboardReducer,

View File

@ -102,10 +102,10 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
<div className="explore-toolbar-header">
<div className="explore-toolbar-header-title">
{exploreId === 'left' && (
<a className="navbar-page-btn">
<span className="navbar-page-btn">
<i className="fa fa-rocket fa-fw" />
Explore
</a>
</span>
)}
</div>
<div className="explore-toolbar-header-close">

View File

@ -7,6 +7,7 @@ import { Emitter } from 'app/core/core';
import getFactors from 'app/core/utils/factors';
import {
duplicatePanel,
removePanel,
copyPanel as copyPanelUtil,
editPanelJson as editPanelJsonUtil,
sharePanel as sharePanelUtil,
@ -213,9 +214,7 @@ export class PanelCtrl {
}
removePanel() {
this.publishAppEvent('panel-remove', {
panelId: this.panel.id,
});
removePanel(this.dashboard, this.panel, true);
}
editPanelJson() {

View File

@ -1,12 +1,16 @@
import coreModule from '../../core/core_module';
import kbn from 'app/core/utils/kbn';
import appEvents from 'app/core/app_events';
// Libraries
import _ from 'lodash';
// Utils
import { toUrlParams } from 'app/core/utils/url';
import coreModule from '../../core/core_module';
import appEvents from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
import kbn from 'app/core/utils/kbn';
export class PlaylistSrv {
private cancelPromise: any;
private dashboards: Array<{ uri: string }>;
private dashboards: Array<{ url: string }>;
private index: number;
private interval: number;
private startUrl: string;
@ -36,7 +40,12 @@ export class PlaylistSrv {
const queryParams = this.$location.search();
const filteredParams = _.pickBy(queryParams, value => value !== null);
this.$location.url('dashboard/' + dash.uri + '?' + toUrlParams(filteredParams));
// this is done inside timeout to make sure digest happens after
// as this can be called from react
this.$timeout(() => {
const stripedUrl = locationUtil.stripBaseFromUrl(dash.url);
this.$location.url(stripedUrl + '?' + toUrlParams(filteredParams));
});
this.index++;
this.cancelPromise = this.$timeout(() => this.next(), this.interval);
@ -54,6 +63,8 @@ export class PlaylistSrv {
this.index = 0;
this.isPlaying = true;
appEvents.emit('playlist-started');
return this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => {
return this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => {
this.dashboards = dashboards;
@ -77,6 +88,8 @@ export class PlaylistSrv {
if (this.cancelPromise) {
this.$timeout.cancel(this.cancelPromise);
}
appEvents.emit('playlist-stopped');
}
}

View File

@ -1,6 +1,6 @@
import { PlaylistSrv } from '../playlist_srv';
const dashboards = [{ uri: 'dash1' }, { uri: 'dash2' }];
const dashboards = [{ url: 'dash1' }, { url: 'dash2' }];
const createPlaylistSrv = (): [PlaylistSrv, { url: jest.MockInstance<any> }] => {
const mockBackendSrv = {
@ -50,13 +50,12 @@ const mockWindowLocation = (): [jest.MockInstance<any>, () => void] => {
describe('PlaylistSrv', () => {
let srv: PlaylistSrv;
let mockLocationService: { url: jest.MockInstance<any> };
let hrefMock: jest.MockInstance<any>;
let unmockLocation: () => void;
const initialUrl = 'http://localhost/playlist';
beforeEach(() => {
[srv, mockLocationService] = createPlaylistSrv();
[srv] = createPlaylistSrv();
[hrefMock, unmockLocation] = mockWindowLocation();
// This will be cached in the srv when start() is called
@ -71,7 +70,6 @@ describe('PlaylistSrv', () => {
await srv.start(1);
for (let i = 0; i < 6; i++) {
expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
srv.next();
}
@ -84,7 +82,6 @@ describe('PlaylistSrv', () => {
// 1 complete loop
for (let i = 0; i < 3; i++) {
expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
srv.next();
}
@ -93,7 +90,6 @@ describe('PlaylistSrv', () => {
// Another 2 loops
for (let i = 0; i < 4; i++) {
expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
srv.next();
}

View File

@ -0,0 +1,14 @@
import { UserState } from 'app/types';
import config from 'app/core/config';
export const initialState: UserState = {
orgId: config.bootData.user.orgId,
};
export const userReducer = (state = initialState, action: any): UserState => {
return state;
};
export default {
user: userReducer,
};

View File

@ -48,7 +48,6 @@ describe('VariableSrv', function(this: any) {
ds.metricFindQuery = () => Promise.resolve(scenario.queryResult);
ctx.variableSrv = new VariableSrv(
ctx.$rootScope,
$q,
ctx.$location,
ctx.$injector,

View File

@ -25,10 +25,6 @@ describe('VariableSrv init', function(this: any) {
};
const $injector = {} as any;
const $rootscope = {
$on: () => {},
};
let ctx = {} as any;
function describeInitScenario(desc, fn) {
@ -54,7 +50,7 @@ describe('VariableSrv init', function(this: any) {
};
// @ts-ignore
ctx.variableSrv = new VariableSrv($rootscope, $q, {}, $injector, templateSrv, timeSrv);
ctx.variableSrv = new VariableSrv($q, {}, $injector, templateSrv, timeSrv);
$injector.instantiate = (variable, model) => {
return getVarMockConstructor(variable, model, ctx);

View File

@ -18,18 +18,18 @@ export class VariableSrv {
variables: any[];
/** @ngInject */
constructor(private $rootScope,
private $q,
constructor(private $q,
private $location,
private $injector,
private templateSrv: TemplateSrv,
private timeSrv: TimeSrv) {
$rootScope.$on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this), $rootScope);
}
init(dashboard: DashboardModel) {
this.dashboard = dashboard;
this.dashboard.events.on('time-range-updated', this.onTimeRangeUpdated.bind(this));
this.dashboard.events.on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this));
// create working class models representing variables
this.variables = dashboard.templating.list = dashboard.templating.list.map(this.createVariableFromModel.bind(this));
@ -59,7 +59,7 @@ export class VariableSrv {
return variable.updateOptions().then(() => {
if (angular.toJson(previousOptions) !== angular.toJson(variable.options)) {
this.$rootScope.$emit('template-variable-value-updated');
this.dashboard.templateVariableValueUpdated();
}
});
});
@ -144,7 +144,7 @@ export class VariableSrv {
return this.$q.all(promises).then(() => {
if (emitChangeEvents) {
this.$rootScope.appEvent('template-variable-value-updated');
this.dashboard.templateVariableValueUpdated();
this.dashboard.startRefresh();
}
});

View File

@ -1,17 +0,0 @@
<div dash-class ng-if="ctrl.dashboard">
<dashnav dashboard="ctrl.dashboard"></dashnav>
<div class="scroll-canvas scroll-canvas--dashboard" page-scrollbar>
<dashboard-settings dashboard="ctrl.dashboard"
ng-if="ctrl.dashboardViewState.state.editview"
class="dashboard-settings">
</dashboard-settings>
<div class="dashboard-container" ng-class="{'dashboard-container--has-submenu': ctrl.dashboard.meta.submenuEnabled}">
<dashboard-submenu ng-if="ctrl.dashboard.meta.submenuEnabled" dashboard="ctrl.dashboard">
</dashboard-submenu>
<dashboard-grid dashboard="ctrl.dashboard"></dashboard-grid>
</div>
</div>
</div>

View File

@ -1,9 +1,11 @@
import config from 'app/core/config';
// Libraries
import _ from 'lodash';
import $ from 'jquery';
import Drop from 'tether-drop';
import { colors } from '@grafana/ui';
// Utils and servies
import { colors } from '@grafana/ui';
import config from 'app/core/config';
import coreModule from 'app/core/core_module';
import { profiler } from 'app/core/profiler';
import appEvents from 'app/core/app_events';
@ -13,6 +15,9 @@ import { DatasourceSrv, setDatasourceSrv } from 'app/features/plugins/datasource
import { AngularLoader, setAngularLoader } from 'app/core/services/AngularLoader';
import { configureStore } from 'app/store/configureStore';
// Types
import { KioskUrlValue } from 'app/types';
export class GrafanaCtrl {
/** @ngInject */
constructor(
@ -46,11 +51,6 @@ export class GrafanaCtrl {
$rootScope.colors = colors;
$scope.initDashboard = (dashboardData, viewScope) => {
$scope.appEvent('dashboard-fetch-end', dashboardData);
$controller('DashboardCtrl', { $scope: viewScope }).init(dashboardData);
};
$rootScope.onAppEvent = function(name, callback, localScope) {
const unbind = $rootScope.$on(name, callback);
let callerScope = this;
@ -72,7 +72,7 @@ export class GrafanaCtrl {
}
}
function setViewModeBodyClass(body, mode, sidemenuOpen: boolean) {
function setViewModeBodyClass(body, mode: KioskUrlValue, sidemenuOpen: boolean) {
body.removeClass('view-mode--tv');
body.removeClass('view-mode--kiosk');
body.removeClass('view-mode--inactive');
@ -126,12 +126,13 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
body.toggleClass('sidemenu-hidden');
});
scope.$watch(
() => playlistSrv.isPlaying,
newValue => {
elem.toggleClass('view-mode--playlist', newValue === true);
}
);
appEvents.on('playlist-started', () => {
elem.toggleClass('view-mode--playlist', true);
});
appEvents.on('playlist-stopped', () => {
elem.toggleClass('view-mode--playlist', false);
});
// check if we are in server side render
if (document.cookie.indexOf('renderKey') !== -1) {
@ -165,6 +166,8 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
for (const drop of Drop.drops) {
drop.destroy();
}
appEvents.emit('hide-dash-search');
});
// handle kiosk mode
@ -262,10 +265,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
}, 100);
}
if (target.parents('.navbar-buttons--playlist').length === 0) {
playlistSrv.stop();
}
// hide search
if (body.find('.search-container').length > 0) {
if (target.parents('.search-results-container, .search-field-wrapper').length === 0) {

View File

@ -44,11 +44,15 @@ export function reactContainer(
$injector: $injector,
$rootScope: $rootScope,
$scope: scope,
routeInfo: $route.current.$$route.routeInfo,
};
document.body.classList.add('is-react');
ReactDOM.render(WrapInProvider(store, component, props), elem[0]);
scope.$on('$destroy', () => {
document.body.classList.remove('is-react');
ReactDOM.unmountComponentAtNode(elem[0]);
});
},

View File

@ -2,6 +2,7 @@ import './dashboard_loaders';
import './ReactContainer';
import { applyRouteRegistrationHandlers } from './registry';
// Pages
import ServerStats from 'app/features/admin/ServerStats';
import AlertRuleList from 'app/features/alerting/AlertRuleList';
import TeamPages from 'app/features/teams/TeamPages';
@ -20,40 +21,66 @@ import DataSourceDashboards from 'app/features/datasources/DataSourceDashboards'
import DataSourceSettingsPage from '../features/datasources/settings/DataSourceSettingsPage';
import OrgDetailsPage from '../features/org/OrgDetailsPage';
import SoloPanelPage from '../features/dashboard/containers/SoloPanelPage';
import DashboardPage from '../features/dashboard/containers/DashboardPage';
import config from 'app/core/config';
// Types
import { DashboardRouteInfo } from 'app/types';
/** @ngInject */
export function setupAngularRoutes($routeProvider, $locationProvider) {
$locationProvider.html5Mode(true);
$routeProvider
.when('/', {
templateUrl: 'public/app/partials/dashboard.html',
controller: 'LoadDashboardCtrl',
reloadOnSearch: false,
template: '<react-container />',
pageClass: 'page-dashboard',
routeInfo: DashboardRouteInfo.Home,
reloadOnSearch: false,
resolve: {
component: () => DashboardPage,
},
})
.when('/d/:uid/:slug', {
templateUrl: 'public/app/partials/dashboard.html',
controller: 'LoadDashboardCtrl',
reloadOnSearch: false,
template: '<react-container />',
pageClass: 'page-dashboard',
routeInfo: DashboardRouteInfo.Normal,
reloadOnSearch: false,
resolve: {
component: () => DashboardPage,
},
})
.when('/d/:uid', {
templateUrl: 'public/app/partials/dashboard.html',
controller: 'LoadDashboardCtrl',
reloadOnSearch: false,
template: '<react-container />',
pageClass: 'page-dashboard',
reloadOnSearch: false,
routeInfo: DashboardRouteInfo.Normal,
resolve: {
component: () => DashboardPage,
},
})
.when('/dashboard/:type/:slug', {
templateUrl: 'public/app/partials/dashboard.html',
controller: 'LoadDashboardCtrl',
reloadOnSearch: false,
template: '<react-container />',
pageClass: 'page-dashboard',
routeInfo: DashboardRouteInfo.Normal,
reloadOnSearch: false,
resolve: {
component: () => DashboardPage,
},
})
.when('/dashboard/new', {
template: '<react-container />',
pageClass: 'page-dashboard',
routeInfo: DashboardRouteInfo.New,
reloadOnSearch: false,
resolve: {
component: () => DashboardPage,
},
})
.when('/d-solo/:uid/:slug', {
template: '<react-container />',
pageClass: 'dashboard-solo',
routeInfo: DashboardRouteInfo.Normal,
resolve: {
component: () => SoloPanelPage,
},
@ -61,16 +88,11 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
.when('/dashboard-solo/:type/:slug', {
template: '<react-container />',
pageClass: 'dashboard-solo',
routeInfo: DashboardRouteInfo.Normal,
resolve: {
component: () => SoloPanelPage,
},
})
.when('/dashboard/new', {
templateUrl: 'public/app/partials/dashboard.html',
controller: 'NewDashboardCtrl',
reloadOnSearch: false,
pageClass: 'page-dashboard',
})
.when('/dashboard/import', {
templateUrl: 'public/app/features/manage-dashboards/partials/dashboard_import.html',
controller: DashboardImportCtrl,

View File

@ -11,6 +11,7 @@ import exploreReducers from 'app/features/explore/state/reducers';
import pluginReducers from 'app/features/plugins/state/reducers';
import dataSourcesReducers from 'app/features/datasources/state/reducers';
import usersReducers from 'app/features/users/state/reducers';
import userReducers from 'app/features/profile/state/reducers';
import organizationReducers from 'app/features/org/state/reducers';
import { setStore } from './store';
@ -25,6 +26,7 @@ const rootReducers = {
...pluginReducers,
...dataSourcesReducers,
...usersReducers,
...userReducers,
...organizationReducers,
};

View File

@ -1,5 +1,71 @@
import { DashboardAcl } from './acl';
export interface DashboardState {
permissions: DashboardAcl[];
export interface MutableDashboard {
title: string;
meta: DashboardMeta;
destroy: () => void;
}
export interface DashboardDTO {
redirectUri?: string;
dashboard: DashboardDataDTO;
meta: DashboardMeta;
}
export interface DashboardMeta {
canSave?: boolean;
canEdit?: boolean;
canShare?: boolean;
canStar?: boolean;
canAdmin?: boolean;
url?: string;
folderId?: number;
fullscreen?: boolean;
isEditing?: boolean;
canMakeEditable?: boolean;
submenuEnabled?: boolean;
provisioned?: boolean;
focusPanelId?: boolean;
isStarred?: boolean;
showSettings?: boolean;
expires?: string;
isSnapshot?: boolean;
folderTitle?: string;
folderUrl?: string;
created?: string;
}
export interface DashboardDataDTO {
title: string;
}
export enum DashboardRouteInfo {
Home = 'home-dashboard',
New = 'new-dashboard',
Normal = 'normal-dashboard',
Scripted = 'scripted-dashboard',
}
export enum DashboardInitPhase {
NotStarted = 'Not started',
Fetching = 'Fetching',
Services = 'Services',
Failed = 'Failed',
Completed = 'Completed',
}
export interface DashboardInitError {
message: string;
error: any;
}
export const KIOSK_MODE_TV = 'tv';
export type KioskUrlValue = 'tv' | '1' | true;
export interface DashboardState {
model: MutableDashboard | null;
initPhase: DashboardInitPhase;
isInitSlow: boolean;
initError?: DashboardInitError;
permissions: DashboardAcl[] | null;
}

View File

@ -3,6 +3,10 @@ export interface LocationUpdate {
query?: UrlQueryMap;
routeParams?: UrlQueryMap;
partial?: boolean;
/*
* If true this will replace url state (ie cause no new browser history)
*/
replace?: boolean;
}
export interface LocationState {
@ -10,6 +14,7 @@ export interface LocationState {
path: string;
query: UrlQueryMap;
routeParams: UrlQueryMap;
replace: boolean;
}
export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[];

View File

@ -1,3 +1,6 @@
import { ThunkAction, ThunkDispatch as GenericThunkDispatch } from 'redux-thunk';
import { ActionOf } from 'app/core/redux';
import { NavIndex } from './navModel';
import { LocationState } from './location';
import { AlertRulesState } from './alerting';
@ -27,3 +30,10 @@ export interface StoreState {
user: UserState;
plugins: PluginsState;
}
/*
* Utility type to get strongly types thunks
*/
export type ThunkResult<R> = ThunkAction<R, StoreState, undefined, ActionOf<any>>;
export type ThunkDispatch = GenericThunkDispatch<StoreState, undefined, any>;

View File

@ -1,5 +1,3 @@
import { DashboardSearchHit } from './search';
export interface OrgUser {
avatarUrl: string;
email: string;
@ -47,5 +45,5 @@ export interface UsersState {
}
export interface UserState {
starredDashboards: DashboardSearchHit[];
orgId: number;
}

View File

@ -16,6 +16,9 @@
opacity: 1;
transition: opacity 300ms ease-in-out;
}
.dashboard-container {
display: none;
}
}
.dashboard-settings__content {

View File

@ -83,8 +83,7 @@
font-size: 19px;
line-height: 8px;
opacity: 0.75;
margin-right: 8px;
// icon hidden on smaller screens
margin-right: 13px;
display: none;
}
@ -102,7 +101,7 @@
display: flex;
align-items: center;
justify-content: flex-end;
margin-right: $spacer;
margin-left: 10px;
&--close {
display: none;

View File

@ -276,3 +276,19 @@ div.flot-text {
.panel-full-edit {
padding-top: $dashboard-padding;
}
.dashboard-loading {
height: 60vh;
display: flex;
align-items: center;
justify-content: center;
.alert {
max-width: 600px;
min-width: 600px;
}
}
.dashboard-loading__text {
font-size: $font-size-lg;
}

View File

@ -189,10 +189,10 @@
<grafana-app class="grafana-app" ng-cloak>
<sidemenu class="sidemenu"></sidemenu>
<app-notifications-list class="page-alert-list"></app-notifications-list>
<dashboard-search></dashboard-search>
<div class="main-view">
<div class="scroll-canvas" page-scrollbar>
<div class="scroll-canvas">
<div ng-view></div>
<footer class="footer">

View File

@ -14582,6 +14582,13 @@ redux-logger@^3.0.6:
dependencies:
deep-diff "^0.3.5"
redux-mock-store@^1.5.3:
version "1.5.3"
resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.5.3.tgz#1f10528949b7ce8056c2532624f7cafa98576c6d"
integrity sha512-ryhkkb/4D4CUGpAV2ln1GOY/uh51aczjcRz9k2L2bPx/Xja3c5pSGJJPyR25GNVRXtKIExScdAgFdiXp68GmJA==
dependencies:
lodash.isplainobject "^4.0.6"
redux-thunk@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622"