Просмотр исходного кода

Merge branch 'develop' of https://github.com/grafana/grafana into develop

Johannes Schill 8 лет назад
Родитель
Сommit
d5023d0073

+ 10 - 0
public/app/core/components/grafana_app.ts

@@ -85,6 +85,16 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
         }
         }
       });
       });
 
 
+      let sidemenuOpenSmallBreakpoint = scope.contextSrv.sidemenuSmallBreakpoint;
+      body.toggleClass('sidemenu-open--xs', sidemenuOpenSmallBreakpoint);
+
+      scope.$watch('contextSrv.sidemenuSmallBreakpoint', newVal => {
+        if (sidemenuOpenSmallBreakpoint !== scope.contextSrv.sidemenuSmallBreakpoint) {
+          sidemenuOpenSmallBreakpoint = scope.contextSrv.sidemenuSmallBreakpoint;
+          body.toggleClass('sidemenu-open--xs', scope.contextSrv.sidemenuSmallBreakpoint);
+        }
+      });
+
       // tooltip removal fix
       // tooltip removal fix
       // manage page classes
       // manage page classes
       var pageClass;
       var pageClass;

+ 6 - 0
public/app/core/components/sidemenu/sidemenu.html

@@ -2,6 +2,12 @@
 	<img src="public/img/grafana_icon.svg"></img>
 	<img src="public/img/grafana_icon.svg"></img>
 </a>
 </a>
 
 
+<a class="sidemenu__logo_small_breakpoint" ng-click="ctrl.toggleSideMenuSmallBreakpoint()">
+  <img src="public/img/grafana_icon.svg"></img>
+  <p class="sidemenu__close"><i class="fa fa-times"></i>&nbsp;Close</p>
+</a>
+
+
 <div class="sidemenu__top">
 <div class="sidemenu__top">
 	<div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
 	<div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
 		<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
 		<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">

+ 10 - 0
public/app/core/components/sidemenu/sidemenu.ts

@@ -11,6 +11,7 @@ export class SideMenuCtrl {
   bottomNav: any;
   bottomNav: any;
   loginUrl: string;
   loginUrl: string;
   isSignedIn: boolean;
   isSignedIn: boolean;
+  smallBPSideMenuOpen = false;
 
 
   /** @ngInject */
   /** @ngInject */
   constructor(private $scope, private $rootScope, private $location, private contextSrv, private $timeout) {
   constructor(private $scope, private $rootScope, private $location, private contextSrv, private $timeout) {
@@ -28,6 +29,10 @@ export class SideMenuCtrl {
     }
     }
 
 
     this.$scope.$on('$routeChangeSuccess', () => {
     this.$scope.$on('$routeChangeSuccess', () => {
+      if (this.smallBPSideMenuOpen) {
+        this.contextSrv.setSideMenuForSmallBreakpoint(false, true);
+        this.smallBPSideMenuOpen = false;
+      }
       this.loginUrl = 'login?redirect=' + encodeURIComponent(this.$location.path());
       this.loginUrl = 'login?redirect=' + encodeURIComponent(this.$location.path());
     });
     });
   }
   }
@@ -39,6 +44,11 @@ export class SideMenuCtrl {
     });
     });
   }
   }
 
 
+  toggleSideMenuSmallBreakpoint() {
+    this.smallBPSideMenuOpen = !this.smallBPSideMenuOpen;
+    this.contextSrv.setSideMenuForSmallBreakpoint(this.smallBPSideMenuOpen, false);
+  }
+
   switchOrg() {
   switchOrg() {
     this.$rootScope.appEvent('show-modal', {
     this.$rootScope.appEvent('show-modal', {
       templateHtml: '<org-switcher dismiss="dismiss()"></org-switcher>',
       templateHtml: '<org-switcher dismiss="dismiss()"></org-switcher>',

+ 7 - 2
public/app/core/services/context_srv.ts

@@ -27,9 +27,10 @@ export class ContextSrv {
   isGrafanaAdmin: any;
   isGrafanaAdmin: any;
   isEditor: any;
   isEditor: any;
   sidemenu: any;
   sidemenu: any;
+  sidemenuSmallBreakpoint = false;
 
 
   constructor() {
   constructor() {
-    this.sidemenu = store.getBool('grafana.sidemenu', false);
+    this.sidemenu = store.getBool('grafana.sidemenu', true);
 
 
     if (!config.buildInfo) {
     if (!config.buildInfo) {
       config.buildInfo = {};
       config.buildInfo = {};
@@ -55,7 +56,11 @@ export class ContextSrv {
 
 
   toggleSideMenu() {
   toggleSideMenu() {
     this.sidemenu = !this.sidemenu;
     this.sidemenu = !this.sidemenu;
-    store.set('grafana.sidemenu',this.sidemenu);
+    store.set('grafana.sidemenu', this.sidemenu);
+  }
+
+  setSideMenuForSmallBreakpoint(show: boolean, persistToggle: boolean) {
+    this.sidemenuSmallBreakpoint = show;
   }
   }
 }
 }
 
 

+ 133 - 38
public/app/features/dashboard/dashboard_model.ts

@@ -181,6 +181,14 @@ export class DashboardModel {
       if (panel.id > max) {
       if (panel.id > max) {
         max = panel.id;
         max = panel.id;
       }
       }
+
+      if (panel.collapsed) {
+        for (let rowPanel of panel.panels) {
+          if (rowPanel.id > max) {
+            max = rowPanel.id;
+          }
+        }
+      }
     }
     }
 
 
     return max + 1;
     return max + 1;
@@ -251,16 +259,6 @@ export class DashboardModel {
       }
       }
     }
     }
 
 
-    // for (let panel of this.panels) {
-    //   if (panel.repeat) {
-    //     if (!cleanUpOnly) {
-    //       this.repeatPanel(panel);
-    //     }
-    //   } else if (panel.repeatPanelId && panel.repeatIteration !== this.iteration) {
-    //     panelsToRemove.push(panel);
-    //   }
-    // }
-
     // remove panels
     // remove panels
     _.pull(this.panels, ...panelsToRemove);
     _.pull(this.panels, ...panelsToRemove);
 
 
@@ -274,21 +272,11 @@ export class DashboardModel {
       return sourcePanel;
       return sourcePanel;
     }
     }
 
 
-    var clone = new PanelModel(sourcePanel.getSaveModel());
+    let clone = new PanelModel(sourcePanel.getSaveModel());
     clone.id = this.getNextPanelId();
     clone.id = this.getNextPanelId();
 
 
-    if (sourcePanel.type === 'row') {
-      // for row clones we need to figure out panels under row to clone and where to insert clone
-      let rowPanels = this.getRowPanels(sourcePanelIndex);
-      clone.panels = _.map(rowPanels, panel => panel.getSaveModel());
-
-      // insert after preceding row's panels
-      let insertPos = sourcePanelIndex + ((rowPanels.length + 1)*valueIndex);
-      this.panels.splice(insertPos, 0, clone);
-    } else {
-      // insert after source panel + value index
-      this.panels.splice(sourcePanelIndex+valueIndex, 0, clone);
-    }
+    // insert after source panel + value index
+    this.panels.splice(sourcePanelIndex+valueIndex, 0, clone);
 
 
     clone.repeatIteration = this.iteration;
     clone.repeatIteration = this.iteration;
     clone.repeatPanelId = sourcePanel.id;
     clone.repeatPanelId = sourcePanel.id;
@@ -296,37 +284,60 @@ export class DashboardModel {
     return clone;
     return clone;
   }
   }
 
 
-  getBottomYForRow() {
+  getRowRepeatClone(sourcePanel, valueIndex, sourcePanelIndex) {
+    // if first clone return source
+    if (valueIndex === 0) {
+      if (!sourcePanel.collapsed) {
+        let rowPanels = this.getRowPanels(sourcePanelIndex);
+        sourcePanel.panels = rowPanels;
+      }
+      return sourcePanel;
+    }
+
+    let clone = new PanelModel(sourcePanel.getSaveModel());
+    // for row clones we need to figure out panels under row to clone and where to insert clone
+    let rowPanels, insertPos;
+    if (sourcePanel.collapsed) {
+      rowPanels = _.cloneDeep(sourcePanel.panels);
+      clone.panels = rowPanels;
+      // insert copied row after preceding row
+      insertPos = sourcePanelIndex + valueIndex;
+    } else {
+      rowPanels = this.getRowPanels(sourcePanelIndex);
+      clone.panels = _.map(rowPanels, panel => panel.getSaveModel());
+      // insert copied row after preceding row's panels
+      insertPos = sourcePanelIndex + ((rowPanels.length + 1)*valueIndex);
+    }
+    this.panels.splice(insertPos, 0, clone);
+
+    this.updateRepeatedPanelIds(clone);
+    return clone;
   }
   }
 
 
   repeatPanel(panel: PanelModel, panelIndex: number) {
   repeatPanel(panel: PanelModel, panelIndex: number) {
-    var variable = _.find(this.templating.list, {name: panel.repeat});
+    let variable = _.find(this.templating.list, {name: panel.repeat});
     if (!variable) {
     if (!variable) {
       return;
       return;
     }
     }
 
 
-    var selected;
-    if (variable.current.text === 'All') {
-      selected = variable.options.slice(1, variable.options.length);
-    } else {
-      selected = _.filter(variable.options, {selected: true});
+    if (panel.type === 'row') {
+      this.repeatRow(panel, panelIndex, variable);
+      return;
     }
     }
 
 
+    let selectedOptions = this.getSelectedVariableOptions(variable);
     let minWidth = panel.minSpan || 6;
     let minWidth = panel.minSpan || 6;
     let xPos = 0;
     let xPos = 0;
     let yPos = panel.gridPos.y;
     let yPos = panel.gridPos.y;
 
 
-    for (let index = 0; index < selected.length; index++) {
-      var option = selected[index];
-      var copy = this.getPanelRepeatClone(panel, index, panelIndex);
+    for (let index = 0; index < selectedOptions.length; index++) {
+      let option = selectedOptions[index];
+      let copy;
 
 
+      copy = this.getPanelRepeatClone(panel, index, panelIndex);
       copy.scopedVars = {};
       copy.scopedVars = {};
       copy.scopedVars[variable.name] = option;
       copy.scopedVars[variable.name] = option;
 
 
-      if (copy.type === 'row') {
-        // place row below row panels
-      }
-
       if (panel.repeatDirection === REPEAT_DIR_VERTICAL) {
       if (panel.repeatDirection === REPEAT_DIR_VERTICAL) {
         copy.gridPos.y = yPos;
         copy.gridPos.y = yPos;
         yPos += copy.gridPos.h;
         yPos += copy.gridPos.h;
@@ -334,7 +345,7 @@ export class DashboardModel {
         // set width based on how many are selected
         // set width based on how many are selected
         // assumed the repeated panels should take up full row width
         // assumed the repeated panels should take up full row width
 
 
-        copy.gridPos.w = Math.max(GRID_COLUMN_COUNT / selected.length, minWidth);
+        copy.gridPos.w = Math.max(GRID_COLUMN_COUNT / selectedOptions.length, minWidth);
         copy.gridPos.x = xPos;
         copy.gridPos.x = xPos;
         copy.gridPos.y = yPos;
         copy.gridPos.y = yPos;
 
 
@@ -349,6 +360,90 @@ export class DashboardModel {
     }
     }
   }
   }
 
 
+  repeatRow(panel: PanelModel, panelIndex: number, variable) {
+    let selectedOptions = this.getSelectedVariableOptions(variable);
+    let yPos = panel.gridPos.y;
+
+    function setScopedVars(panel, variableOption) {
+      panel.scopedVars = {};
+      panel.scopedVars[variable.name] = variableOption;
+    }
+
+    for (let optionIndex = 0; optionIndex < selectedOptions.length; optionIndex++) {
+      let option = selectedOptions[optionIndex];
+      let rowCopy = this.getRowRepeatClone(panel, optionIndex, panelIndex);
+      setScopedVars(rowCopy, option);
+
+      let rowHeight = this.getRowHeight(rowCopy);
+      let rowPanels = rowCopy.panels || [];
+      let panelBelowIndex;
+
+      if (panel.collapsed) {
+        // For collapsed row just copy its panels and set scoped vars and proper IDs
+        _.each(rowPanels, (rowPanel, i) => {
+          setScopedVars(rowPanel, option);
+          if (optionIndex > 0) {
+            this.updateRepeatedPanelIds(rowPanel);
+          }
+        });
+        rowCopy.gridPos.y += optionIndex;
+        yPos += optionIndex;
+        panelBelowIndex = panelIndex + optionIndex + 1;
+      } else {
+        // insert after 'row' panel
+        let insertPos = panelIndex + ((rowPanels.length + 1) * optionIndex) + 1;
+        _.each(rowPanels, (rowPanel, i) => {
+          setScopedVars(rowPanel, option);
+          if (optionIndex > 0) {
+            let cloneRowPanel = new PanelModel(rowPanel);
+            this.updateRepeatedPanelIds(cloneRowPanel);
+            // For exposed row additionally set proper Y grid position and add it to dashboard panels
+            cloneRowPanel.gridPos.y += rowHeight * optionIndex;
+            this.panels.splice(insertPos+i, 0, cloneRowPanel);
+          }
+        });
+        rowCopy.panels = [];
+        rowCopy.gridPos.y += rowHeight * optionIndex;
+        yPos += rowHeight;
+        panelBelowIndex = insertPos+rowPanels.length;
+      }
+
+      // Update gridPos for panels below
+      for (let i = panelBelowIndex; i< this.panels.length; i++) {
+        this.panels[i].gridPos.y += yPos;
+      }
+    }
+  }
+
+  updateRepeatedPanelIds(panel: PanelModel) {
+    panel.repeatPanelId = panel.id;
+    panel.id = this.getNextPanelId();
+    panel.repeatIteration = this.iteration;
+    panel.repeat = null;
+    return panel;
+  }
+
+  getSelectedVariableOptions(variable) {
+    let selectedOptions;
+    if (variable.current.text === 'All') {
+      selectedOptions = variable.options.slice(1, variable.options.length);
+    } else {
+      selectedOptions = _.filter(variable.options, {selected: true});
+    }
+    return selectedOptions;
+  }
+
+  getRowHeight(rowPanel: PanelModel): number {
+    if (!rowPanel.panels || rowPanel.panels.length === 0) {
+      return 0;
+    }
+    const positions = _.map(rowPanel.panels, 'gridPos');
+    const maxPos = _.maxBy(positions, (pos) => {
+      return pos.y + pos.h;
+    });
+    return maxPos.h + 1;
+  }
+
   removePanel(panel: PanelModel) {
   removePanel(panel: PanelModel) {
     var index = _.indexOf(this.panels, panel);
     var index = _.indexOf(this.panels, panel);
     this.panels.splice(index, 1);
     this.panels.splice(index, 1);

+ 134 - 28
public/app/features/dashboard/specs/repeat.jest.ts

@@ -1,4 +1,6 @@
+import _ from 'lodash';
 import {DashboardModel} from '../dashboard_model';
 import {DashboardModel} from '../dashboard_model';
+import { expect } from 'test/lib/common';
 
 
 jest.mock('app/core/services/context_srv', () => ({
 jest.mock('app/core/services/context_srv', () => ({
 
 
@@ -146,19 +148,19 @@ describe('given dashboard with panel repeat in vertical direction', function() {
   });
   });
 });
 });
 
 
-describe.skip('given dashboard with row repeat', function() {
-  var dashboard;
+describe('given dashboard with row repeat', function() {
+  let dashboard, dashboardJSON;
 
 
   beforeEach(function() {
   beforeEach(function() {
-    dashboard = new DashboardModel({
+    dashboardJSON = {
       panels: [
       panels: [
-        {id: 1, type: 'row',   repeat: 'apps', gridPos: {x: 0, y: 0, h: 1 , w: 24}},
+        {id: 1, type: 'row',   gridPos: {x: 0, y: 0, h: 1 , w: 24}, repeat: 'apps'},
         {id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
         {id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
         {id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
         {id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
         {id: 4, type: 'row',   gridPos: {x: 0, y: 2, h: 1 , w: 24}},
         {id: 4, type: 'row',   gridPos: {x: 0, y: 2, h: 1 , w: 24}},
         {id: 5, type: 'graph', gridPos: {x: 0, y: 3, h: 1 , w: 12}},
         {id: 5, type: 'graph', gridPos: {x: 0, y: 3, h: 1 , w: 12}},
       ],
       ],
-      templating:  {
+      templating: {
         list: [{
         list: [{
           name: 'apps',
           name: 'apps',
           current: {
           current: {
@@ -172,33 +174,137 @@ describe.skip('given dashboard with row repeat', function() {
           ]
           ]
         }]
         }]
       }
       }
-    });
+    };
+    dashboard = new DashboardModel(dashboardJSON);
     dashboard.processRepeats();
     dashboard.processRepeats();
   });
   });
 
 
   it('should not repeat only row', function() {
   it('should not repeat only row', function() {
-    expect(dashboard.panels[1].type).toBe('graph');
-  });
-  //
-  // it('should set scopedVars on panels', function() {
-  //   expect(dashboard.panels[1].scopedVars).toMatchObject({apps: {text: 'se1', value: 'se1'}})
-  // });
-  //
-  // it.skip('should repeat row and panels below two times', function() {
-  //   expect(dashboard.panels).toMatchObject([
-  //     // first (original row)
-  //     {id: 1, type: 'row',   repeat: 'apps', gridPos: {x: 0, y: 0, h: 1 , w: 24}},
-  //     {id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
-  //     {id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
-  //     // repeated row
-  //     {id: 1, type: 'row',   repeatPanelId: 1, gridPos: {x: 0, y: 0, h: 1 , w: 24}},
-  //     {id: 2, type: 'graph', repeatPanelId: 1, gridPos: {x: 0, y: 1, h: 1 , w: 6}},
-  //     {id: 3, type: 'graph', repeatPanelId: 1, gridPos: {x: 6, y: 1, h: 1 , w: 6}},
-  //     // row below dont touch
-  //     {id: 4, type: 'row',   gridPos: {x: 0, y: 2, h: 1 , w: 24}},
-  //     {id: 5, type: 'graph', gridPos: {x: 0, y: 3, h: 1 , w: 12}},
-  //   ]);
-  // });
+    const panel_types = _.map(dashboard.panels, 'type');
+    expect(panel_types).toEqual([
+      'row', 'graph', 'graph',
+      'row', 'graph', 'graph',
+      'row', 'graph'
+    ]);
+  });
+
+  it('should set scopedVars for each panel', function() {
+    dashboardJSON.templating.list[0].options[2].selected = true;
+    dashboard = new DashboardModel(dashboardJSON);
+    dashboard.processRepeats();
+
+    expect(dashboard.panels[1].scopedVars).toMatchObject({apps: {text: 'se1', value: 'se1'}});
+    expect(dashboard.panels[4].scopedVars).toMatchObject({apps: {text: 'se2', value: 'se2'}});
+
+    const scopedVars = _.compact(_.map(dashboard.panels, (panel) => {
+      return panel.scopedVars ? panel.scopedVars.apps.value : null;
+    }));
+
+    expect(scopedVars).toEqual([
+      'se1', 'se1', 'se1',
+      'se2', 'se2', 'se2',
+      'se3', 'se3', 'se3',
+    ]);
+  });
+
+  it('should repeat only configured row', function() {
+    expect(dashboard.panels[6].id).toBe(4);
+    expect(dashboard.panels[7].id).toBe(5);
+  });
+
+  it('should repeat only row if it is collapsed', function() {
+    dashboardJSON.panels = [
+        {
+          id: 1, type: 'row', collapsed: true, repeat: 'apps', gridPos: {x: 0, y: 0, h: 1 , w: 24},
+          panels: [
+            {id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
+            {id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
+          ]
+        },
+        {id: 4, type: 'row',   gridPos: {x: 0, y: 1, h: 1 , w: 24}},
+        {id: 5, type: 'graph', gridPos: {x: 0, y: 2, h: 1 , w: 12}},
+    ];
+    dashboard = new DashboardModel(dashboardJSON);
+    dashboard.processRepeats();
+
+    const panel_types = _.map(dashboard.panels, 'type');
+    expect(panel_types).toEqual([
+      'row', 'row', 'row', 'graph'
+    ]);
+    expect(dashboard.panels[0].panels).toHaveLength(2);
+    expect(dashboard.panels[1].panels).toHaveLength(2);
+  });
+
+  it('should properly repeat multiple rows', function() {
+    dashboardJSON.panels = [
+      {id: 1, type: 'row',   gridPos: {x: 0, y: 0, h: 1 , w: 24}, repeat: 'apps'}, // repeat
+      {id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
+      {id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
+      {id: 4, type: 'row',   gridPos: {x: 0, y: 2, h: 1 , w: 24}}, // don't touch
+      {id: 5, type: 'graph', gridPos: {x: 0, y: 3, h: 1 , w: 12}},
+      {id: 6, type: 'row',   gridPos: {x: 0, y: 4, h: 1 , w: 24}, repeat: 'hosts'}, // repeat
+      {id: 7, type: 'graph', gridPos: {x: 0, y: 5, h: 1 , w: 6}},
+      {id: 8, type: 'graph', gridPos: {x: 6, y: 5, h: 1 , w: 6}}
+    ];
+    dashboardJSON.templating.list.push({
+      name: 'hosts',
+      current: {
+        text: 'backend01, backend02',
+        value: ['backend01', 'backend02']
+      },
+      options: [
+        {text: 'backend01', value: 'backend01', selected: true},
+        {text: 'backend02', value: 'backend02', selected: true},
+        {text: 'backend03', value: 'backend03', selected: false}
+      ]
+    });
+    dashboard = new DashboardModel(dashboardJSON);
+    dashboard.processRepeats();
+
+    const panel_types = _.map(dashboard.panels, 'type');
+    expect(panel_types).toEqual([
+      'row', 'graph', 'graph',
+      'row', 'graph', 'graph',
+      'row', 'graph',
+      'row', 'graph', 'graph',
+      'row', 'graph', 'graph',
+    ]);
+
+    expect(dashboard.panels[0].scopedVars['apps'].value).toBe('se1');
+    expect(dashboard.panels[1].scopedVars['apps'].value).toBe('se1');
+    expect(dashboard.panels[3].scopedVars['apps'].value).toBe('se2');
+    expect(dashboard.panels[4].scopedVars['apps'].value).toBe('se2');
+    expect(dashboard.panels[8].scopedVars['hosts'].value).toBe('backend01');
+    expect(dashboard.panels[9].scopedVars['hosts'].value).toBe('backend01');
+    expect(dashboard.panels[11].scopedVars['hosts'].value).toBe('backend02');
+    expect(dashboard.panels[12].scopedVars['hosts'].value).toBe('backend02');
+  });
+
+  it('should assign unique ids for repeated panels', function() {
+    dashboardJSON.panels = [
+        {
+          id: 1, type: 'row', collapsed: true, repeat: 'apps', gridPos: {x: 0, y: 0, h: 1 , w: 24},
+          panels: [
+            {id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
+            {id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
+          ]
+        },
+        {id: 4, type: 'row',   gridPos: {x: 0, y: 1, h: 1 , w: 24}},
+        {id: 5, type: 'graph', gridPos: {x: 0, y: 2, h: 1 , w: 12}},
+    ];
+    dashboard = new DashboardModel(dashboardJSON);
+    dashboard.processRepeats();
+
+    const panel_ids = _.flattenDeep(_.map(dashboard.panels, (panel) => {
+      let ids = [];
+      if (panel.panels && panel.panels.length) {
+        ids = _.map(panel.panels, 'id');
+      }
+      ids.push(panel.id);
+      return ids;
+    }));
+    expect(panel_ids.length).toEqual(_.uniq(panel_ids).length);
+  });
 });
 });
 
 
 
 

+ 120 - 28
public/sass/components/_sidemenu.scss

@@ -4,25 +4,34 @@
   flex-flow: column;
   flex-flow: column;
   flex-direction: column;
   flex-direction: column;
   width: $side-menu-width;
   width: $side-menu-width;
-  background: $navbarBackground;
   z-index: $zindex-sidemenu;
   z-index: $zindex-sidemenu;
   a:focus {
   a:focus {
     text-decoration: none;
     text-decoration: none;
   }
   }
-}
 
 
-.sidemenu-open {
-  .sidemenu {
-    background: $side-menu-bg;
-    position: initial;
-    height: auto;
-    box-shadow: $side-menu-shadow;
-    position: relative;
-    z-index: $zindex-sidemenu;
+  .sidemenu__logo_small_breakpoint {
+    display: none;
   }
   }
-  .sidemenu__top,
-  .sidemenu__bottom {
-    display: block;
+
+  .sidemenu__close {
+    display: none;
+  }
+}
+
+@include media-breakpoint-up(sm) {
+  .sidemenu-open {
+    .sidemenu {
+      background: $side-menu-bg;
+      position: initial;
+      height: auto;
+      box-shadow: $side-menu-shadow;
+      position: relative;
+      z-index: $zindex-sidemenu;
+    }
+    .sidemenu__top,
+    .sidemenu__bottom {
+      display: block;
+    }
   }
   }
 }
 }
 
 
@@ -41,21 +50,23 @@
   position: relative;
   position: relative;
   @include left-brand-border();
   @include left-brand-border();
 
 
-  &.active,
-  &:hover {
-    background-color: $side-menu-item-hover-bg;
-    @include left-brand-border-gradient();
+  @include media-breakpoint-up(sm) {
+    &.active,
+    &:hover {
+      background-color: $side-menu-item-hover-bg;
+      @include left-brand-border-gradient();
 
 
-    .dropdown-menu {
-      margin: 0;
-      display: block;
-      opacity: 0;
-      top: 0px;
-      // important to overlap it otherwise it can be hidden
-      // again by the mouse getting outside the hover space
-      left: $side-menu-width - 2px;
-      @include animation('dropdown-anim 150ms ease-in-out 100ms forwards');
-      z-index: $zindex-sidemenu;
+      .dropdown-menu {
+        margin: 0;
+        display: block;
+        opacity: 0;
+        top: 0px;
+        // important to overlap it otherwise it can be hidden
+        // again by the mouse getting outside the hover space
+        left: $side-menu-width - 2px;
+        @include animation('dropdown-anim 150ms ease-in-out 100ms forwards');
+        z-index:  $zindex-sidemenu;
+      }
     }
     }
   }
   }
 }
 }
@@ -152,7 +163,7 @@ li.sidemenu-org-switcher {
   }
   }
 }
 }
 
 
-.sidemenu__logo {
+.sidemenu__logo, .sidemenu__logo_small_breakpoint {
   display: block;
   display: block;
   padding: 0.4rem 1.0rem 0.4rem 0.65rem;
   padding: 0.4rem 1.0rem 0.4rem 0.65rem;
   min-height: $navbarHeight;
   min-height: $navbarHeight;
@@ -170,3 +181,84 @@ li.sidemenu-org-switcher {
   }
   }
 }
 }
 
 
+@include media-breakpoint-down(xs) {
+  .sidemenu-open {
+    .navbar {
+      padding-left: 60px !important;
+    }
+  }
+
+  .sidemenu-open--xs {
+    .sidemenu {
+      width: 100%;
+      background: $side-menu-bg;
+      position: initial;
+      height: auto;
+      box-shadow: $side-menu-shadow;
+      position: relative;
+      z-index: $zindex-sidemenu;
+    }
+
+    .sidemenu__close {
+      display: block;
+      font-size: $font-size-md;
+    }
+
+    .sidemenu__top,
+    .sidemenu__bottom {
+      display: block;
+    }
+  }
+
+  .sidemenu {
+    .sidemenu__logo {
+      display: none;
+    }
+    .sidemenu__logo_small_breakpoint {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+      align-items: baseline;
+
+      &:hover {
+        background: transparent;
+      }
+    }
+
+    .sidemenu__top {
+      padding-top: 0rem;
+    }
+
+    .side-menu-header {
+      padding-left: 10px;
+    }
+
+    .sidemenu-link {
+      text-align: left;
+    }
+
+    .sidemenu-icon {
+      display: none
+    }
+
+    .dropdown-menu--sidemenu {
+      display: block;
+      position: unset;
+      width: 100%;
+      float: none;
+      margin-top: 0.5rem;
+      margin-bottom: 0.5rem;
+
+      > li > a {
+        padding-left: 15px;
+      }
+    }
+
+    .sidemenu__bottom {
+      .dropdown-menu--sidemenu {
+        display: flex;
+        flex-direction: column-reverse;
+      }
+    }
+  }
+}