فهرست منبع

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

Dashboard react page
Torkel Ödegaard 7 سال پیش
والد
کامیت
78ea7ae783
73فایلهای تغییر یافته به همراه2544 افزوده شده و 946 حذف شده
  1. 1 0
      package.json
  2. 1 0
      pkg/api/dtos/playlist.go
  3. 1 0
      pkg/api/playlist_play.go
  4. 2 1
      public/app/core/app_events.ts
  5. 42 0
      public/app/core/components/AlertBox/AlertBox.tsx
  6. 8 12
      public/app/core/components/AppNotifications/AppNotificationItem.tsx
  7. 0 7
      public/app/core/components/Page/Page.tsx
  8. 0 40
      public/app/core/components/gf_page.ts
  9. 0 43
      public/app/core/components/scroll/page_scroll.ts
  10. 1 1
      public/app/core/config.ts
  11. 9 6
      public/app/core/copy/appNotification.ts
  12. 0 4
      public/app/core/core.ts
  13. 3 1
      public/app/core/reducers/location.ts
  14. 15 0
      public/app/core/redux/actionCreatorFactory.ts
  15. 2 4
      public/app/core/redux/index.ts
  16. 14 0
      public/app/core/services/__mocks__/backend_srv.ts
  17. 4 0
      public/app/core/services/bridge_srv.ts
  18. 4 6
      public/app/core/services/keybindingSrv.ts
  19. 17 0
      public/app/core/utils/errors.ts
  20. 1 1
      public/app/core/utils/location_util.ts
  21. 6 1
      public/app/features/annotations/editor_ctrl.ts
  22. 4 2
      public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts
  23. 3 1
      public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts
  24. 253 0
      public/app/features/dashboard/components/DashNav/DashNav.tsx
  25. 33 0
      public/app/features/dashboard/components/DashNav/DashNavButton.tsx
  26. 0 119
      public/app/features/dashboard/components/DashNav/DashNavCtrl.ts
  27. 2 1
      public/app/features/dashboard/components/DashNav/index.ts
  28. 0 61
      public/app/features/dashboard/components/DashNav/template.html
  29. 1 0
      public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx
  30. 2 2
      public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx
  31. 36 0
      public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx
  32. 1 0
      public/app/features/dashboard/components/DashboardSettings/index.ts
  33. 36 0
      public/app/features/dashboard/components/SubMenu/SubMenu.tsx
  34. 1 0
      public/app/features/dashboard/components/SubMenu/index.ts
  35. 1 1
      public/app/features/dashboard/components/SubMenu/template.html
  36. 0 156
      public/app/features/dashboard/containers/DashboardCtrl.ts
  37. 251 0
      public/app/features/dashboard/containers/DashboardPage.test.tsx
  38. 309 0
      public/app/features/dashboard/containers/DashboardPage.tsx
  39. 39 52
      public/app/features/dashboard/containers/SoloPanelPage.tsx
  40. 546 0
      public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap
  41. 27 14
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  42. 0 2
      public/app/features/dashboard/index.ts
  43. 68 19
      public/app/features/dashboard/services/DashboardSrv.ts
  44. 0 64
      public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
  45. 0 185
      public/app/features/dashboard/services/DashboardViewStateSrv.ts
  46. 34 11
      public/app/features/dashboard/state/DashboardModel.ts
  47. 28 23
      public/app/features/dashboard/state/actions.ts
  48. 152 0
      public/app/features/dashboard/state/initDashboard.test.ts
  49. 233 0
      public/app/features/dashboard/state/initDashboard.ts
  50. 56 9
      public/app/features/dashboard/state/reducers.test.ts
  51. 78 9
      public/app/features/dashboard/state/reducers.ts
  52. 2 2
      public/app/features/explore/ExploreToolbar.tsx
  53. 2 3
      public/app/features/panel/panel_ctrl.ts
  54. 18 5
      public/app/features/playlist/playlist_srv.ts
  55. 2 6
      public/app/features/playlist/specs/playlist_srv.test.ts
  56. 14 0
      public/app/features/profile/state/reducers.ts
  57. 0 1
      public/app/features/templating/specs/variable_srv.test.ts
  58. 1 5
      public/app/features/templating/specs/variable_srv_init.test.ts
  59. 5 5
      public/app/features/templating/variable_srv.ts
  60. 0 17
      public/app/partials/dashboard.html
  61. 17 18
      public/app/routes/GrafanaCtrl.ts
  62. 4 0
      public/app/routes/ReactContainer.tsx
  63. 39 17
      public/app/routes/routes.ts
  64. 2 0
      public/app/store/configureStore.ts
  65. 67 1
      public/app/types/dashboard.ts
  66. 5 0
      public/app/types/location.ts
  67. 10 0
      public/app/types/store.ts
  68. 1 3
      public/app/types/user.ts
  69. 3 0
      public/sass/components/_dashboard_settings.scss
  70. 2 3
      public/sass/components/_navbar.scss
  71. 16 0
      public/sass/pages/_dashboard.scss
  72. 2 2
      public/views/index-template.html
  73. 7 0
      yarn.lock

+ 1 - 0
package.json

@@ -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",

+ 1 - 0
pkg/api/dtos/playlist.go

@@ -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"`
 }
 

+ 1 - 0
pkg/api/playlist_play.go

@@ -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],
 			})
 		}

+ 2 - 1
public/app/core/app_events.ts

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

+ 42 - 0
public/app/core/components/AlertBox/AlertBox.tsx

@@ -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>
+  );
+};

+ 8 - 12
public/app/core/components/AppNotifications/AppNotificationItem.tsx

@@ -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)}
+      />
     );
   }
 }

+ 0 - 7
public/app/core/components/Page/Page.tsx

@@ -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';

+ 0 - 40
public/app/core/components/gf_page.ts

@@ -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);

+ 0 - 43
public/app/core/components/scroll/page_scroll.ts

@@ -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);

+ 1 - 1
public/app/core/config.ts

@@ -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;

+ 9 - 6
public/app/core/copy/appNotification.ts

@@ -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,

+ 0 - 4
public/app/core/core.ts

@@ -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,

+ 3 - 1
public/app/core/reducers/location.ts

@@ -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,
       };
     }
   }

+ 15 - 0
public/app/core/redux/actionCreatorFactory.ts

@@ -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);

+ 2 - 4
public/app/core/redux/index.ts

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

+ 14 - 0
public/app/core/services/__mocks__/backend_srv.ts

@@ -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;
+}
+
+

+ 4 - 0
public/app/core/services/bridge_srv.ts

@@ -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);
       }

+ 4 - 6
public/app/core/services/keybindingSrv.ts

@@ -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;
       }
     });

+ 17 - 0
public/app/core/utils/errors.ts

@@ -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;
+}

+ 1 - 1
public/app/core/utils/location_util.ts

@@ -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 =

+ 6 - 1
public/app/features/annotations/editor_ctrl.ts

@@ -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) {

+ 4 - 2
public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts

@@ -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: '=',
     },
   };
 }

+ 3 - 1
public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts

@@ -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) {

+ 253 - 0
public/app/features/dashboard/components/DashNav/DashNav.tsx

@@ -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);

+ 33 - 0
public/app/features/dashboard/components/DashNav/DashNavButton.tsx

@@ -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>
+  );
+};

+ 0 - 119
public/app/features/dashboard/components/DashNav/DashNavCtrl.ts

@@ -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);

+ 2 - 1
public/app/features/dashboard/components/DashNav/index.ts

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

+ 0 - 61
public/app/features/dashboard/components/DashNav/template.html

@@ -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>

+ 1 - 0
public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx

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

+ 2 - 2
public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx

@@ -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 = () => {

+ 36 - 0
public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx

@@ -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} />;
+  }
+}

+ 1 - 0
public/app/features/dashboard/components/DashboardSettings/index.ts

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

+ 36 - 0
public/app/features/dashboard/components/SubMenu/SubMenu.tsx

@@ -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} />;
+  }
+}

+ 1 - 0
public/app/features/dashboard/components/SubMenu/index.ts

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

+ 1 - 1
public/app/features/dashboard/components/SubMenu/template.html

@@ -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">

+ 0 - 156
public/app/features/dashboard/containers/DashboardCtrl.ts

@@ -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);

+ 251 - 0
public/app/features/dashboard/containers/DashboardPage.test.tsx

@@ -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);
+    });
+  });
+});

+ 309 - 0
public/app/features/dashboard/containers/DashboardPage.tsx

@@ -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));

+ 39 - 52
public/app/features/dashboard/containers/SoloPanelPage.tsx

@@ -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;
+
+    this.props.initDashboard({
+      $injector: $injector,
+      $scope: $scope,
+      urlSlug: urlSlug,
+      urlUid: urlUid,
+      urlType: urlType,
+      routeInfo: routeInfo,
+      fixUrl: false,
+    });
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    const { urlPanelId, dashboard } = this.props;
 
-    // handle old urls with no uid
-    if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) {
-      this.redirectToNewUrl();
+    if (!dashboard) {
       return;
     }
 
-    const dashboardLoaderSrv = $injector.get('dashboardLoaderSrv');
+    // we just got the dashboard!
+    if (!prevProps.dashboard) {
+      const panelId = parseInt(urlPanelId, 10);
 
-    // subscribe to event to know when dashboard controller is done with inititalization
-    appEvents.on('dashboard-initialized', this.onDashoardInitialized);
+      // need to expand parent row if this panel is inside a row
+      dashboard.expandParentRowFor(panelId);
 
-    dashboardLoaderSrv.loadDashboard(urlType, urlSlug, urlUid).then(result => {
-      result.meta.soloMode = true;
-      $scope.initDashboard(result, $scope);
-    });
-  }
+      const panel = dashboard.getPanelById(panelId);
 
-  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);
+      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));

+ 546 - 0
public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap

@@ -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>
+`;

+ 27 - 14
public/app/features/dashboard/dashgrid/DashboardGrid.tsx

@@ -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>

+ 0 - 2
public/app/features/dashboard/index.ts

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

+ 68 - 19
public/app/features/dashboard/services/DashboardSrv.ts

@@ -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;
     });

+ 0 - 64
public/app/features/dashboard/services/DashboardViewStateSrv.test.ts

@@ -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);
-    });
-  });
-});

+ 0 - 185
public/app/features/dashboard/services/DashboardViewStateSrv.ts

@@ -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);

+ 34 - 11
public/app/features/dashboard/state/DashboardModel.ts

@@ -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;
+          }
+        }
+      }
+    }
+  }
+
 }

+ 28 - 23
public/app/features/dashboard/state/actions.ts

@@ -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 const dashboardInitSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_INIT_SLOW').create();
 
-export type Action = LoadDashboardPermissionsAction | LoadStarredDashboardsAction;
+export const dashboardInitCompleted = actionCreatorFactory<MutableDashboard>('DASHBOARD_INIT_COMLETED').create();
 
-type ThunkResult<R> = ThunkAction<R, StoreState, undefined, any>;
+/*
+ * Unrecoverable init failure (fetch or model creation failed)
+ */
+export const dashboardInitFailed = actionCreatorFactory<DashboardInitError>('DASHBOARD_INIT_FAILED').create();
 
-export const loadDashboardPermissions = (items: DashboardAclDTO[]): LoadDashboardPermissionsAction => ({
-  type: ActionTypes.LoadDashboardPermissions,
-  payload: items,
-});
+/*
+ * 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());
   };
 }
+

+ 152 - 0
public/app/features/dashboard/state/initDashboard.test.ts

@@ -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');
+  });
+});
+
+

+ 233 - 0
public/app/features/dashboard/state/initDashboard.ts

@@ -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;
+}

+ 56 - 9
public/app/features/dashboard/state/reducers.test.ts

@@ -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);
+    });
+  });
 });

+ 78 - 9
public/app/features/dashboard/state/reducers.ts

@@ -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,

+ 2 - 2
public/app/features/explore/ExploreToolbar.tsx

@@ -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">

+ 2 - 3
public/app/features/panel/panel_ctrl.ts

@@ -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() {

+ 18 - 5
public/app/features/playlist/playlist_srv.ts

@@ -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');
   }
 }
 

+ 2 - 6
public/app/features/playlist/specs/playlist_srv.test.ts

@@ -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();
     }
 

+ 14 - 0
public/app/features/profile/state/reducers.ts

@@ -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,
+};

+ 0 - 1
public/app/features/templating/specs/variable_srv.test.ts

@@ -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,

+ 1 - 5
public/app/features/templating/specs/variable_srv_init.test.ts

@@ -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);

+ 5 - 5
public/app/features/templating/variable_srv.ts

@@ -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();
       }
     });

+ 0 - 17
public/app/partials/dashboard.html

@@ -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>

+ 17 - 18
public/app/routes/GrafanaCtrl.ts

@@ -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) {

+ 4 - 0
public/app/routes/ReactContainer.tsx

@@ -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]);
       });
     },

+ 39 - 17
public/app/routes/routes.ts

@@ -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',
+      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,

+ 2 - 0
public/app/store/configureStore.ts

@@ -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,
 };
 

+ 67 - 1
public/app/types/dashboard.ts

@@ -1,5 +1,71 @@
 import { DashboardAcl } from './acl';
 
+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 {
-  permissions: DashboardAcl[];
+  model: MutableDashboard | null;
+  initPhase: DashboardInitPhase;
+  isInitSlow: boolean;
+  initError?: DashboardInitError;
+  permissions: DashboardAcl[] | null;
 }

+ 5 - 0
public/app/types/location.ts

@@ -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[];

+ 10 - 0
public/app/types/store.ts

@@ -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>;

+ 1 - 3
public/app/types/user.ts

@@ -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;
 }

+ 3 - 0
public/sass/components/_dashboard_settings.scss

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

+ 2 - 3
public/sass/components/_navbar.scss

@@ -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;

+ 16 - 0
public/sass/pages/_dashboard.scss

@@ -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;
+}

+ 2 - 2
public/views/index-template.html

@@ -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">

+ 7 - 0
yarn.lock

@@ -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"