ryan 6 gadi atpakaļ
vecāks
revīzija
08d6540cbb
100 mainītis faili ar 3043 papildinājumiem un 1220 dzēšanām
  1. 4 4
      .circleci/config.yml
  2. 10 0
      CHANGELOG.md
  3. 1 1
      conf/defaults.ini
  4. 1 1
      conf/sample.ini
  5. 5 0
      docs/sources/installation/configuration.md
  6. 3 0
      docs/sources/permissions/organization_roles.md
  7. 1 1
      docs/sources/reference/templating.md
  8. 2 2
      latest.json
  9. 5 5
      package.json
  10. 3 3
      packages/grafana-ui/package.json
  11. 11 4
      packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx
  12. 1 0
      packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx
  13. 178 96
      packages/grafana-ui/src/components/BarGauge/BarGauge.tsx
  14. 15 331
      packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap
  15. 11 13
      packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx
  16. 5 7
      packages/grafana-ui/src/components/Input/Input.test.tsx
  17. 14 27
      packages/grafana-ui/src/components/Input/Input.tsx
  18. 0 0
      packages/grafana-ui/src/components/Input/__snapshots__/Input.test.tsx.snap
  19. 1 0
      packages/grafana-ui/src/components/Select/SelectOption.test.tsx
  20. 1 0
      packages/grafana-ui/src/components/Select/__snapshots__/SelectOption.test.tsx.snap
  21. 16 0
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.story.tsx
  22. 17 16
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
  23. 7 3
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
  24. 446 3
      packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap
  25. 1 0
      packages/grafana-ui/src/components/index.ts
  26. 1 0
      packages/grafana-ui/src/types/index.ts
  27. 0 0
      packages/grafana-ui/src/types/input.ts
  28. 1 0
      packages/grafana-ui/src/types/plugin.ts
  29. 1 0
      packages/grafana-ui/src/utils/index.ts
  30. 24 0
      packages/grafana-ui/src/utils/validate.ts
  31. 14 12
      pkg/api/api.go
  32. 11 1
      pkg/api/dashboard.go
  33. 2 0
      pkg/api/dashboard_test.go
  34. 8 1
      pkg/api/folder.go
  35. 7 1
      pkg/api/folder_test.go
  36. 1 1
      pkg/api/frontendsettings.go
  37. 21 0
      pkg/api/index.go
  38. 69 17
      pkg/api/team.go
  39. 49 5
      pkg/api/team_members.go
  40. 8 2
      pkg/api/team_test.go
  41. 17 0
      pkg/middleware/auth.go
  42. 19 14
      pkg/models/team.go
  43. 32 20
      pkg/models/team_member.go
  44. 55 0
      pkg/services/dashboards/acl_service.go
  45. 4 0
      pkg/services/sqlstore/migrations/team_mig.go
  46. 103 19
      pkg/services/sqlstore/team.go
  47. 93 0
      pkg/services/sqlstore/team_test.go
  48. 34 0
      pkg/services/teamguardian/team.go
  49. 87 0
      pkg/services/teamguardian/teams_test.go
  50. 3 4
      pkg/setting/setting.go
  51. 0 1
      public/app/core/components/Form/index.ts
  52. 13 0
      public/app/core/components/WithFeatureToggle.tsx
  53. 1 0
      public/app/core/components/sidemenu/BottomNavLinks.test.tsx
  54. 3 3
      public/app/core/config.ts
  55. 1 0
      public/app/core/services/context_srv.ts
  56. 5 8
      public/app/core/utils/explore.ts
  57. 30 0
      public/app/core/utils/query.test.ts
  58. 12 0
      public/app/core/utils/query.ts
  59. 0 16
      public/app/core/utils/validate.ts
  60. 10 7
      public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts
  61. 47 14
      public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts
  62. 2 7
      public/app/features/dashboard/panel_editor/QueryOptions.tsx
  63. 13 2
      public/app/features/dashboard/state/PanelModel.test.ts
  64. 7 14
      public/app/features/dashboard/state/PanelModel.ts
  65. 11 9
      public/app/features/explore/state/actions.ts
  66. 5 3
      public/app/features/explore/state/reducers.ts
  67. 43 1
      public/app/features/teams/TeamList.test.tsx
  68. 17 5
      public/app/features/teams/TeamList.tsx
  69. 90 0
      public/app/features/teams/TeamMemberRow.test.tsx
  70. 106 0
      public/app/features/teams/TeamMemberRow.tsx
  71. 15 28
      public/app/features/teams/TeamMembers.test.tsx
  72. 33 35
      public/app/features/teams/TeamMembers.tsx
  73. 52 1
      public/app/features/teams/TeamPages.test.tsx
  74. 51 12
      public/app/features/teams/TeamPages.tsx
  75. 6 2
      public/app/features/teams/__mocks__/teamMocks.ts
  76. 257 0
      public/app/features/teams/__snapshots__/TeamList.test.tsx.snap
  77. 250 0
      public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap
  78. 108 410
      public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap
  79. 23 0
      public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap
  80. 9 0
      public/app/features/teams/state/actions.ts
  81. 2 1
      public/app/features/teams/state/navModel.ts
  82. 95 3
      public/app/features/teams/state/selectors.test.ts
  83. 31 1
      public/app/features/teams/state/selectors.ts
  84. 4 3
      public/app/plugins/panel/bargauge/BarGaugePanel.tsx
  85. 12 1
      public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx
  86. 13 6
      public/app/plugins/panel/bargauge/types.ts
  87. 3 3
      public/app/plugins/panel/gauge/GaugePanel.tsx
  88. 3 3
      public/app/plugins/panel/singlestat2/ProcessedValuesRepeater.tsx
  89. 3 3
      public/app/plugins/panel/singlestat2/SingleStatPanel.tsx
  90. 2 2
      public/app/routes/routes.ts
  91. 20 0
      public/app/types/acl.ts
  92. 0 1
      public/app/types/index.ts
  93. 4 0
      public/app/types/teams.ts
  94. 6 0
      public/sass/pages/_admin.scss
  95. 2 1
      public/test/specs/helpers.ts
  96. 114 0
      scripts/build/ci-build/Dockerfile
  97. 54 0
      scripts/build/ci-build/Makefile
  98. 20 0
      scripts/build/ci-build/README.md
  99. 5 0
      scripts/build/ci-build/bootstrap.sh
  100. 7 0
      scripts/build/ci-build/build-deploy.sh

+ 4 - 4
.circleci/config.yml

@@ -322,7 +322,7 @@ jobs:
 
   deploy-enterprise-master:
     docker:
-      - image: grafana/grafana-ci-deploy:1.2.0
+      - image: grafana/grafana-ci-deploy:1.2.1
     steps:
       - attach_workspace:
           at: .
@@ -345,7 +345,7 @@ jobs:
 
   deploy-enterprise-release:
     docker:
-    - image: grafana/grafana-ci-deploy:1.2.0
+    - image: grafana/grafana-ci-deploy:1.2.1
     steps:
       - checkout
       - attach_workspace:
@@ -378,7 +378,7 @@ jobs:
 
   deploy-master:
     docker:
-      - image: grafana/grafana-ci-deploy:1.2.0
+      - image: grafana/grafana-ci-deploy:1.2.1
     steps:
       - attach_workspace:
           at: .
@@ -409,7 +409,7 @@ jobs:
 
   deploy-release:
     docker:
-      - image: grafana/grafana-ci-deploy:1.2.0
+      - image: grafana/grafana-ci-deploy:1.2.1
     steps:
       - checkout
       - attach_workspace:

+ 10 - 0
CHANGELOG.md

@@ -20,6 +20,16 @@
 * **Datasource**: Empty user/password was not updated when updating datasources [#15608](https://github.com/grafana/grafana/pull/15608), thx [@Maddin-619](https://github.com/Maddin-619)
 * **Heatmap**: legend shows wrong colors for small values [#14019](https://github.com/grafana/grafana/issues/14019)
 
+# 6.0.2 (2019-03-19)
+
+### Bug Fixes
+* **Alerting**: Fixed issue with AlertList panel links resulting in panel not found errors. [#15975](https://github.com/grafana/grafana/pull/15975), [@torkelo](https://github.com/torkelo)
+* **Dashboard**: Improved error handling when rendering dashboard panels. [#15970](https://github.com/grafana/grafana/pull/15970), [@torkelo](https://github.com/torkelo)
+* **LDAP**: Fix allow anonymous server bind for ldap search. [#15872](https://github.com/grafana/grafana/pull/15872), [@marefr](https://github.com/marefr)
+* **Discord**: Fix discord notifier so it doesn't crash when there are no image generated. [#15833](https://github.com/grafana/grafana/pull/15833), [@marefr](https://github.com/marefr)
+* **Panel Edit**: Prevent search in VizPicker from stealing focus. [#15802](https://github.com/grafana/grafana/pull/15802), [@peterholmberg](https://github.com/peterholmberg)
+* **Datasource admin**: Fixed url of back button in datasource edit page, when root_url configured. [#15759](https://github.com/grafana/grafana/pull/15759), [@dprokop](https://github.com/dprokop)
+
 # 6.0.1 (2019-03-06)
 
 ### Bug Fixes

+ 1 - 1
conf/defaults.ini

@@ -259,7 +259,7 @@ external_manage_info =
 viewers_can_edit = false
 
 # Editors can administrate dashboard, folders and teams they create
-editors_can_own = false
+editors_can_admin = false
 
 [auth]
 # Login cookie name

+ 1 - 1
conf/sample.ini

@@ -239,7 +239,7 @@ log_queries =
 ;viewers_can_edit = false
 
 # Editors can administrate dashboard, folders and teams they create
-;editors_can_own = false
+;editors_can_admin = false
 
 [auth]
 # Login cookie name

+ 5 - 0
docs/sources/installation/configuration.md

@@ -354,6 +354,11 @@ options are `Admin` and `Editor`. e.g. :
 Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
 Defaults to `false`.
 
+### editors_can_admin
+
+Editors can administrate dashboards, folders and teams they create.
+Defaults to `false`.
+
 ### login_hint
 
 Text used as placeholder text on login page for login/username input.

+ 3 - 0
docs/sources/permissions/organization_roles.md

@@ -28,6 +28,9 @@ Can do everything scoped to the organization. For example:
 - Can create and modify dashboards & alert rules. This can be disabled on specific folders and dashboards.
 - **Cannot** create or edit data sources nor invite new users.
 
+This role can be tweaked via Grafana server setting [editors_can_admin]({{< relref "installation/configuration.md#editors_can_admin" >}}). If you set this to true users
+with **Editor** can also administrate dashboards, folders and teams they create. Useful for enabling self organizing teams.
+
 ## Viewer Role
 
 - View any dashboard. This can be disabled on specific folders and dashboards.

+ 1 - 1
docs/sources/reference/templating.md

@@ -110,7 +110,7 @@ Formats single & multi valued variables for use in URL parameters.
 
 ```bash
 servers = ['foo()bar BAZ', 'test2']
-String to interpolate: '${servers:lucene}'
+String to interpolate: '${servers:percentencode}'
 Interpolation result: 'foo%28%29bar%20BAZ%2Ctest2'
 ```
 

+ 2 - 2
latest.json

@@ -1,4 +1,4 @@
 {
-  "stable": "6.0.1",
-  "testing": "6.0.1"
+  "stable": "6.0.2",
+  "testing": "6.0.2"
 }

+ 5 - 5
package.json

@@ -27,8 +27,8 @@
     "@types/jest": "^23.3.2",
     "@types/jquery": "^1.10.35",
     "@types/node": "^8.0.31",
-    "@types/react": "^16.7.6",
-    "@types/react-dom": "^16.0.9",
+    "@types/react": "^16.8.8",
+    "@types/react-dom": "^16.8.2",
     "@types/react-grid-layout": "^0.16.6",
     "@types/react-select": "^2.0.4",
     "@types/react-transition-group": "^2.0.15",
@@ -191,8 +191,8 @@
     "prismjs": "^1.6.0",
     "prop-types": "^15.6.2",
     "rc-cascader": "^0.14.0",
-    "react": "^16.6.3",
-    "react-dom": "^16.6.3",
+    "react": "^16.8.4",
+    "react-dom": "^16.8.4",
     "react-grid-layout": "0.16.6",
     "react-highlight-words": "0.11.0",
     "react-popper": "^1.3.0",
@@ -219,7 +219,7 @@
   },
   "resolutions": {
     "caniuse-db": "1.0.30000772",
-    "**/@types/react": "16.7.6"
+    "**/@types/react": "16.8.8"
   },
   "workspaces": {
     "packages": [

+ 3 - 3
packages/grafana-ui/package.json

@@ -25,10 +25,10 @@
     "lodash": "^4.17.10",
     "moment": "^2.22.2",
     "papaparse": "^4.6.3",
-    "react": "^16.6.3",
+    "react": "^16.8.4",
     "react-color": "^2.17.0",
     "react-custom-scrollbars": "^4.2.1",
-    "react-dom": "^16.6.3",
+    "react-dom": "^16.8.4",
     "react-highlight-words": "0.11.0",
     "react-popper": "^1.3.0",
     "react-transition-group": "^2.2.1",
@@ -48,7 +48,7 @@
     "@types/lodash": "^4.14.119",
     "@types/node": "^10.12.18",
     "@types/papaparse": "^4.5.9",
-    "@types/react": "^16.7.6",
+    "@types/react": "^16.8.8",
     "@types/react-custom-scrollbars": "^4.0.5",
     "@types/react-test-renderer": "^16.0.3",
     "@types/react-transition-group": "^2.0.15",

+ 11 - 4
packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx

@@ -1,6 +1,7 @@
 import { storiesOf } from '@storybook/react';
-import { number, text } from '@storybook/addon-knobs';
+import { number, text, boolean } from '@storybook/addon-knobs';
 import { BarGauge } from './BarGauge';
+import { VizOrientation } from '../../types';
 import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
 import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
 
@@ -15,6 +16,8 @@ const getKnobs = () => {
     threshold2Color: text('threshold2Color', 'red'),
     unit: text('unit', 'ms'),
     decimals: number('decimals', 1),
+    horizontal: boolean('horizontal', false),
+    lcd: boolean('lcd', false),
   };
 };
 
@@ -22,7 +25,7 @@ const BarGaugeStories = storiesOf('UI/BarGauge/BarGauge', module);
 
 BarGaugeStories.addDecorator(withCenteredStory);
 
-BarGaugeStories.add('Vertical, with basic thresholds', () => {
+BarGaugeStories.add('Simple with basic thresholds', () => {
   const {
     value,
     minValue,
@@ -33,11 +36,13 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => {
     threshold2Value,
     unit,
     decimals,
+    horizontal,
+    lcd,
   } = getKnobs();
 
   return renderComponentWithTheme(BarGauge, {
-    width: 200,
-    height: 400,
+    width: 700,
+    height: 700,
     value: value,
     minValue: minValue,
     maxValue: maxValue,
@@ -45,6 +50,8 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => {
     prefix: '',
     postfix: '',
     decimals: decimals,
+    orientation: horizontal ? VizOrientation.Horizontal : VizOrientation.Vertical,
+    displayMode: lcd ? 'lcd' : 'simple',
     thresholds: [
       { index: 0, value: -Infinity, color: 'green' },
       { index: 1, value: threshold1Value, color: threshold1Color },

+ 1 - 0
packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx

@@ -12,6 +12,7 @@ const setup = (propOverrides?: object) => {
   const props: Props = {
     maxValue: 100,
     minValue: 0,
+    displayMode: 'basic',
     thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
     height: 300,
     width: 300,

+ 178 - 96
packages/grafana-ui/src/components/BarGauge/BarGauge.tsx

@@ -1,5 +1,5 @@
 // Library
-import React, { PureComponent, CSSProperties } from 'react';
+import React, { PureComponent, CSSProperties, ReactNode } from 'react';
 import tinycolor from 'tinycolor2';
 
 // Utils
@@ -18,11 +18,9 @@ export interface Props extends Themeable {
   maxValue: number;
   minValue: number;
   orientation: VizOrientation;
+  displayMode: 'basic' | 'lcd' | 'gradient';
 }
 
-/*
- * This visualization is still in POC state, needed more tests & better structure
- */
 export class BarGauge extends PureComponent<Props> {
   static defaultProps: Partial<Props> = {
     maxValue: 100,
@@ -31,10 +29,22 @@ export class BarGauge extends PureComponent<Props> {
       text: '100',
       numeric: 100,
     },
+    displayMode: 'lcd',
     orientation: VizOrientation.Horizontal,
     thresholds: [],
   };
 
+  render() {
+    switch (this.props.displayMode) {
+      case 'lcd':
+        return this.renderRetroBars();
+      case 'basic':
+      case 'gradient':
+      default:
+        return this.renderBasicAndGradientBars();
+    }
+  }
+
   getValueColors(): BarColors {
     const { thresholds, theme, value } = this.props;
 
@@ -46,41 +56,19 @@ export class BarGauge extends PureComponent<Props> {
       return {
         value: color,
         border: color,
-        bar: tinycolor(color)
-          .setAlpha(0.3)
+        background: tinycolor(color)
+          .setAlpha(0.15)
           .toRgbString(),
       };
     }
 
     return {
       value: getColorFromHexRgbOrName('gray', theme.type),
-      bar: getColorFromHexRgbOrName('gray', theme.type),
+      background: getColorFromHexRgbOrName('gray', theme.type),
       border: getColorFromHexRgbOrName('gray', theme.type),
     };
   }
 
-  getCellColor(positionValue: TimeSeriesValue): string {
-    const { thresholds, theme, value } = this.props;
-    const activeThreshold = getThresholdForValue(thresholds, positionValue);
-
-    if (activeThreshold !== null) {
-      const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
-
-      // if we are past real value the cell is not "on"
-      if (value === null || (positionValue !== null && positionValue > value.numeric)) {
-        return tinycolor(color)
-          .setAlpha(0.15)
-          .toRgbString();
-      } else {
-        return tinycolor(color)
-          .setAlpha(0.7)
-          .toRgbString();
-      }
-    }
-
-    return 'gray';
-  }
-
   getValueStyles(value: string, color: string, width: number): CSSProperties {
     const guess = width / (value.length * 1.1);
     const fontSize = Math.min(Math.max(guess, 14), 40);
@@ -91,107 +79,205 @@ export class BarGauge extends PureComponent<Props> {
     };
   }
 
-  renderVerticalBar(valueFormatted: string, valuePercent: number) {
+  /*
+   * Return width or height depending on viz orientation
+   * */
+  get size() {
     const { height, width } = this.props;
+    return this.isVertical ? height : width;
+  }
 
-    const maxHeight = height * BAR_SIZE_RATIO;
-    const barHeight = Math.max(valuePercent * maxHeight, 0);
+  get isVertical() {
+    return this.props.orientation === VizOrientation.Vertical;
+  }
+
+  getBarGradient(maxSize: number): string {
+    const { minValue, maxValue, thresholds, value } = this.props;
+    const cssDirection = this.isVertical ? '0deg' : '90deg';
+
+    let gradient = '';
+    let lastpos = 0;
+
+    for (let i = 0; i < thresholds.length; i++) {
+      const threshold = thresholds[i];
+      const color = getColorFromHexRgbOrName(threshold.color);
+      const valuePercent = Math.min(threshold.value / (maxValue - minValue), 1);
+      const pos = valuePercent * maxSize;
+      const offset = Math.round(pos - (pos - lastpos) / 2);
+
+      if (gradient === '') {
+        gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`;
+      } else if (value.numeric < threshold.value) {
+        break;
+      } else {
+        lastpos = pos;
+        gradient += ` ${offset}px, ${color}`;
+      }
+    }
+
+    return gradient + ')';
+  }
+
+  renderBasicAndGradientBars(): ReactNode {
+    const { height, width, displayMode, maxValue, minValue, value } = this.props;
+
+    const valuePercent = Math.min(value.numeric / (maxValue - minValue), 1);
+    const maxSize = this.size * BAR_SIZE_RATIO;
+    const barSize = Math.max(valuePercent * maxSize, 0);
     const colors = this.getValueColors();
-    const valueStyles = this.getValueStyles(valueFormatted, colors.value, width);
+    const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
+    const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
+    const isBasic = displayMode === 'basic';
 
     const containerStyles: CSSProperties = {
       width: `${width}px`,
       height: `${height}px`,
       display: 'flex',
-      flexDirection: 'column',
-      justifyContent: 'flex-end',
     };
 
     const barStyles: CSSProperties = {
-      height: `${barHeight}px`,
-      width: `${width}px`,
-      backgroundColor: colors.bar,
-      borderTop: `1px solid ${colors.border}`,
+      borderRadius: '3px',
     };
 
+    if (this.isVertical) {
+      // Custom styles for vertical orientation
+      containerStyles.flexDirection = 'column';
+      containerStyles.justifyContent = 'flex-end';
+      barStyles.transition = 'height 1s';
+      barStyles.height = `${barSize}px`;
+      barStyles.width = `${width}px`;
+      if (isBasic) {
+        // Basic styles
+        barStyles.background = `${colors.background}`;
+        barStyles.border = `1px solid ${colors.border}`;
+        barStyles.boxShadow = `0 0 4px ${colors.border}`;
+      } else {
+        // Gradient styles
+        barStyles.background = this.getBarGradient(maxSize);
+      }
+    } else {
+      // Custom styles for horizontal orientation
+      containerStyles.flexDirection = 'row-reverse';
+      containerStyles.justifyContent = 'flex-end';
+      containerStyles.alignItems = 'center';
+      barStyles.transition = 'width 1s';
+      barStyles.height = `${height}px`;
+      barStyles.width = `${barSize}px`;
+      barStyles.marginRight = '10px';
+
+      if (isBasic) {
+        // Basic styles
+        barStyles.background = `${colors.background}`;
+        barStyles.border = `1px solid ${colors.border}`;
+        barStyles.boxShadow = `0 0 4px ${colors.border}`;
+      } else {
+        // Gradient styles
+        barStyles.background = this.getBarGradient(maxSize);
+      }
+    }
+
     return (
       <div style={containerStyles}>
         <div className="bar-gauge__value" style={valueStyles}>
-          {valueFormatted}
+          {value.text}
         </div>
         <div style={barStyles} />
       </div>
     );
   }
 
-  renderHorizontalBar(valueFormatted: string, valuePercent: number) {
-    const { height, width } = this.props;
-
-    const maxWidth = width * BAR_SIZE_RATIO;
-    const barWidth = Math.max(valuePercent * maxWidth, 0);
-    const colors = this.getValueColors();
-    const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO));
+  getCellColor(positionValue: TimeSeriesValue): CellColors {
+    const { thresholds, theme, value } = this.props;
+    const activeThreshold = getThresholdForValue(thresholds, positionValue);
 
-    valueStyles.marginLeft = '8px';
+    if (activeThreshold !== null) {
+      const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type);
 
-    const containerStyles: CSSProperties = {
-      width: `${width}px`,
-      height: `${height}px`,
-      display: 'flex',
-      flexDirection: 'row',
-      alignItems: 'center',
-    };
+      // if we are past real value the cell is not "on"
+      if (value === null || (positionValue !== null && positionValue > value.numeric)) {
+        return {
+          background: tinycolor(color)
+            .setAlpha(0.15)
+            .toRgbString(),
+          border: 'transparent',
+          isLit: false,
+        };
+      } else {
+        return {
+          background: tinycolor(color)
+            .setAlpha(0.85)
+            .toRgbString(),
+          backgroundShade: tinycolor(color)
+            .setAlpha(0.55)
+            .toRgbString(),
+          border: tinycolor(color)
+            .setAlpha(0.9)
+            .toRgbString(),
+          isLit: true,
+        };
+      }
+    }
 
-    const barStyles = {
-      height: `${height}px`,
-      width: `${barWidth}px`,
-      backgroundColor: colors.bar,
-      borderRight: `1px solid ${colors.border}`,
+    return {
+      background: 'gray',
+      border: 'gray',
     };
-
-    return (
-      <div style={containerStyles}>
-        <div style={barStyles} />
-        <div className="bar-gauge__value" style={valueStyles}>
-          {valueFormatted}
-        </div>
-      </div>
-    );
   }
 
-  renderHorizontalLCD(valueFormatted: string, valuePercent: number) {
-    const { height, width, maxValue, minValue } = this.props;
+  renderRetroBars(): ReactNode {
+    const { height, width, maxValue, minValue, value } = this.props;
 
     const valueRange = maxValue - minValue;
-    const maxWidth = width * BAR_SIZE_RATIO;
-    const cellSpacing = 4;
-    const cellCount = 30;
-    const cellWidth = (maxWidth - cellSpacing * cellCount) / cellCount;
+    const maxSize = this.size * BAR_SIZE_RATIO;
+    const cellSpacing = 5;
+    const cellCount = maxSize / 20;
+    const cellSize = (maxSize - cellSpacing * cellCount) / cellCount;
     const colors = this.getValueColors();
-    const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO));
-    valueStyles.marginLeft = '8px';
+    const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height);
+    const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText);
 
     const containerStyles: CSSProperties = {
       width: `${width}px`,
       height: `${height}px`,
       display: 'flex',
-      flexDirection: 'row',
-      alignItems: 'center',
     };
 
+    if (this.isVertical) {
+      containerStyles.flexDirection = 'column-reverse';
+      containerStyles.alignItems = 'center';
+      valueStyles.marginBottom = '20px';
+    } else {
+      containerStyles.flexDirection = 'row';
+      containerStyles.alignItems = 'center';
+      valueStyles.marginLeft = '20px';
+    }
+
     const cells: JSX.Element[] = [];
 
     for (let i = 0; i < cellCount; i++) {
       const currentValue = (valueRange / cellCount) * i;
       const cellColor = this.getCellColor(currentValue);
       const cellStyles: CSSProperties = {
-        width: `${cellWidth}px`,
-        backgroundColor: cellColor,
-        marginRight: '4px',
-        height: `${height}px`,
         borderRadius: '2px',
       };
 
+      if (cellColor.isLit) {
+        cellStyles.boxShadow = `0 0 4px ${cellColor.border}`;
+        cellStyles.backgroundImage = `radial-gradient(${cellColor.background} 10%, ${cellColor.backgroundShade})`;
+      } else {
+        cellStyles.backgroundColor = cellColor.background;
+      }
+
+      if (this.isVertical) {
+        cellStyles.height = `${cellSize}px`;
+        cellStyles.width = `${width}px`;
+        cellStyles.marginTop = `${cellSpacing}px`;
+      } else {
+        cellStyles.width = `${cellSize}px`;
+        cellStyles.height = `${height}px`;
+        cellStyles.marginRight = `${cellSpacing}px`;
+      }
+
       cells.push(<div style={cellStyles} />);
     }
 
@@ -199,26 +285,22 @@ export class BarGauge extends PureComponent<Props> {
       <div style={containerStyles}>
         {cells}
         <div className="bar-gauge__value" style={valueStyles}>
-          {valueFormatted}
+          {value.text}
         </div>
       </div>
     );
   }
-
-  render() {
-    const { maxValue, minValue, orientation, value } = this.props;
-
-    const valuePercent = Math.min(value.numeric / (maxValue - minValue), 1);
-    const vertical = orientation === 'vertical';
-
-    return vertical
-      ? this.renderVerticalBar(value.text, valuePercent)
-      : this.renderHorizontalLCD(value.text, valuePercent);
-  }
 }
 
 interface BarColors {
   value: string;
-  bar: string;
+  background: string;
+  border: string;
+}
+
+interface CellColors {
+  background: string;
+  backgroundShade?: string;
   border: string;
+  isLit?: boolean;
 }

+ 15 - 331
packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap

@@ -6,353 +6,37 @@ exports[`Render BarGauge with basic options should render 1`] = `
     Object {
       "alignItems": "center",
       "display": "flex",
-      "flexDirection": "row",
+      "flexDirection": "row-reverse",
       "height": "300px",
+      "justifyContent": "flex-end",
       "width": "300px",
     }
   }
 >
   <div
+    className="bar-gauge__value"
     style={
       Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.7)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
-      }
-    }
-  />
-  <div
-    style={
-      Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
-        "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
+        "color": "#7EB26D",
+        "fontSize": "27.27272727272727px",
       }
     }
-  />
+  >
+    25
+  </div>
   <div
     style={
       Object {
-        "backgroundColor": "rgba(126, 178, 109, 0.15)",
-        "borderRadius": "2px",
+        "background": "rgba(126, 178, 109, 0.15)",
+        "border": "1px solid #7EB26D",
+        "borderRadius": "3px",
+        "boxShadow": "0 0 4px #7EB26D",
         "height": "300px",
-        "marginRight": "4px",
-        "width": "4px",
+        "marginRight": "10px",
+        "transition": "width 1s",
+        "width": "60px",
       }
     }
   />
-  <div
-    className="bar-gauge__value"
-    style={
-      Object {
-        "color": "#7EB26D",
-        "fontSize": "27.272727272727263px",
-        "marginLeft": "8px",
-      }
-    }
-  >
-    25
-  </div>
 </div>
 `;

+ 11 - 13
packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx

@@ -2,6 +2,7 @@ import React, { PureComponent, SyntheticEvent } from 'react';
 
 interface Props {
   onConfirm(): void;
+  disabled?: boolean;
 }
 
 interface State {
@@ -33,25 +34,22 @@ export class DeleteButton extends PureComponent<Props, State> {
   };
 
   render() {
-    const { onConfirm } = this.props;
-    let showConfirm;
-    let showDeleteButton;
-
-    if (this.state.showConfirm) {
-      showConfirm = 'show';
-      showDeleteButton = 'hide';
-    } else {
-      showConfirm = 'hide';
-      showDeleteButton = 'show';
-    }
+    const { onConfirm, disabled } = this.props;
+    const showConfirmClass = this.state.showConfirm ? 'show' : 'hide';
+    const showDeleteButtonClass = this.state.showConfirm ? 'hide' : 'show';
+    const disabledClass = disabled ? 'disabled btn-inverse' : '';
+    const onClick = disabled ? () => {} : this.onClickDelete;
 
     return (
       <span className="delete-button-container">
-        <a className={'delete-button ' + showDeleteButton + ' btn btn-danger btn-small'} onClick={this.onClickDelete}>
+        <a
+          className={`delete-button ${showDeleteButtonClass} btn btn-danger btn-small ${disabledClass}`}
+          onClick={onClick}
+        >
           <i className="fa fa-remove" />
         </a>
         <span className="confirm-delete-container">
-          <span className={'confirm-delete ' + showConfirm}>
+          <span className={`confirm-delete ${showConfirmClass}`}>
             <a className="btn btn-small" onClick={this.onClickCancel}>
               Cancel
             </a>

+ 5 - 7
public/app/core/components/Form/Input.test.tsx → packages/grafana-ui/src/components/Input/Input.test.tsx

@@ -1,18 +1,16 @@
-import React from 'react';
+import React from 'react';
 import renderer from 'react-test-renderer';
 import { shallow } from 'enzyme';
-import { Input, EventsWithValidation } from './Input';
-import { ValidationEvents } from 'app/types';
+import { Input } from './Input';
+import { EventsWithValidation } from '../../utils';
+import { ValidationEvents } from '../../types';
 
 const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars';
 const testBlurValidation: ValidationEvents = {
   [EventsWithValidation.onBlur]: [
     {
       rule: (value: string) => {
-        if (!value || value.length < 3) {
-          return true;
-        }
-        return false;
+        return !value || value.length < 3;
       },
       errorMessage: TEST_ERROR_MESSAGE,
     },

+ 14 - 27
public/app/core/components/Form/Input.tsx → packages/grafana-ui/src/components/Input/Input.tsx

@@ -1,26 +1,13 @@
-import React, { PureComponent } from 'react';
+import React, { PureComponent, ChangeEvent } from 'react';
 import classNames from 'classnames';
-import { ValidationEvents, ValidationRule } from 'app/types';
-import { validate, hasValidationEvent } from 'app/core/utils/validate';
+import { validate, EventsWithValidation, hasValidationEvent } from '../../utils';
+import { ValidationEvents, ValidationRule } from '../../types';
 
 export enum InputStatus {
   Invalid = 'invalid',
   Valid = 'valid',
 }
 
-export enum InputTypes {
-  Text = 'text',
-  Number = 'number',
-  Password = 'password',
-  Email = 'email',
-}
-
-export enum EventsWithValidation {
-  onBlur = 'onBlur',
-  onFocus = 'onFocus',
-  onChange = 'onChange',
-}
-
 interface Props extends React.HTMLProps<HTMLInputElement> {
   validationEvents?: ValidationEvents;
   hideErrorMessage?: boolean;
@@ -28,7 +15,7 @@ interface Props extends React.HTMLProps<HTMLInputElement> {
   // Override event props and append status as argument
   onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
   onFocus?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
-  onChange?: (event: React.FormEvent<HTMLInputElement>, status?: InputStatus) => void;
+  onChange?: (event: React.ChangeEvent<HTMLInputElement>, status?: InputStatus) => void;
 }
 
 export class Input extends PureComponent<Props> {
@@ -49,24 +36,24 @@ export class Input extends PureComponent<Props> {
   }
 
   validatorAsync = (validationRules: ValidationRule[]) => {
-    return evt => {
+    return (evt: ChangeEvent<HTMLInputElement>) => {
       const errors = validate(evt.target.value, validationRules);
       this.setState(prevState => {
-        return {
-          ...prevState,
-          error: errors ? errors[0] : null,
-        };
+        return { ...prevState, error: errors ? errors[0] : null };
       });
     };
   };
 
-  populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => {
+  populateEventPropsWithStatus = (restProps: any, validationEvents: ValidationEvents | undefined) => {
     const inputElementProps = { ...restProps };
-    Object.keys(EventsWithValidation).forEach((eventName: EventsWithValidation) => {
-      if (hasValidationEvent(eventName, validationEvents) || restProps[eventName]) {
-        inputElementProps[eventName] = async evt => {
+    if (!validationEvents) {
+      return inputElementProps;
+    }
+    Object.keys(EventsWithValidation).forEach(eventName => {
+      if (hasValidationEvent(eventName as EventsWithValidation, validationEvents) || restProps[eventName]) {
+        inputElementProps[eventName] = async (evt: ChangeEvent<HTMLInputElement>) => {
           evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling
-          if (hasValidationEvent(eventName, validationEvents)) {
+          if (hasValidationEvent(eventName as EventsWithValidation, validationEvents)) {
             await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]);
           }
           if (restProps[eventName]) {

+ 0 - 0
public/app/core/components/Form/__snapshots__/Input.test.tsx.snap → packages/grafana-ui/src/components/Input/__snapshots__/Input.test.tsx.snap


+ 1 - 0
packages/grafana-ui/src/components/Select/SelectOption.test.tsx

@@ -25,6 +25,7 @@ const model: OptionProps<any> = {
     key: '',
     onClick: jest.fn(),
     onMouseOver: jest.fn(),
+    onMouseMove: jest.fn(),
     tabIndex: 1,
   },
   label: 'Option label',

+ 1 - 0
packages/grafana-ui/src/components/Select/__snapshots__/SelectOption.test.tsx.snap

@@ -4,6 +4,7 @@ exports[`SelectOption renders correctly 1`] = `
 <div
   id=""
   onClick={[MockFunction]}
+  onMouseMove={[MockFunction]}
   onMouseOver={[MockFunction]}
   tabIndex={1}
 >

+ 16 - 0
packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.story.tsx

@@ -0,0 +1,16 @@
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+import { action } from '@storybook/addon-actions';
+
+import { ThresholdsEditor } from './ThresholdsEditor';
+
+const ThresholdsEditorStories = storiesOf('UI/ThresholdsEditor', module);
+const thresholds = [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 50, color: 'red' }];
+
+ThresholdsEditorStories.add('default', () => {
+  return <ThresholdsEditor thresholds={[]} onChange={action('Thresholds changed')} />;
+});
+
+ThresholdsEditorStories.add('with thresholds', () => {
+  return <ThresholdsEditor thresholds={thresholds} onChange={action('Thresholds changed')} />;
+});

+ 17 - 16
packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx

@@ -1,6 +1,7 @@
 import React, { ChangeEvent } from 'react';
 import { mount } from 'enzyme';
 import { ThresholdsEditor, Props } from './ThresholdsEditor';
+import { colors } from '../../utils';
 
 const setup = (propOverrides?: Partial<Props>) => {
   const props: Props = {
@@ -31,7 +32,7 @@ describe('Initialization', () => {
   it('should add a base threshold if missing', () => {
     const { instance } = setup();
 
-    expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
+    expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]);
   });
 });
 
@@ -41,7 +42,7 @@ describe('Add threshold', () => {
 
     instance.onAddThreshold(0);
 
-    expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
+    expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]);
   });
 
   it('should add threshold', () => {
@@ -50,41 +51,41 @@ describe('Add threshold', () => {
     instance.onAddThreshold(1);
 
     expect(instance.state.thresholds).toEqual([
-      { index: 0, value: -Infinity, color: '#7EB26D' },
-      { index: 1, value: 50, color: '#EAB839' },
+      { index: 0, value: -Infinity, color: colors[0] },
+      { index: 1, value: 50, color: colors[2] },
     ]);
   });
 
   it('should add another threshold above a first', () => {
     const { instance } = setup({
-      thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }],
+      thresholds: [{ index: 0, value: -Infinity, color: colors[0] }, { index: 1, value: 50, color: colors[2] }],
     });
 
     instance.onAddThreshold(2);
 
     expect(instance.state.thresholds).toEqual([
-      { index: 0, value: -Infinity, color: '#7EB26D' },
-      { index: 1, value: 50, color: '#EAB839' },
-      { index: 2, value: 75, color: '#6ED0E0' },
+      { index: 0, value: -Infinity, color: colors[0] },
+      { index: 1, value: 50, color: colors[2] },
+      { index: 2, value: 75, color: colors[3] },
     ]);
   });
 
   it('should add another threshold between first and second index', () => {
     const { instance } = setup({
       thresholds: [
-        { index: 0, value: -Infinity, color: '#7EB26D' },
-        { index: 1, value: 50, color: '#EAB839' },
-        { index: 2, value: 75, color: '#6ED0E0' },
+        { index: 0, value: -Infinity, color: colors[0] },
+        { index: 1, value: 50, color: colors[2] },
+        { index: 2, value: 75, color: colors[3] },
       ],
     });
 
     instance.onAddThreshold(2);
 
     expect(instance.state.thresholds).toEqual([
-      { index: 0, value: -Infinity, color: '#7EB26D' },
-      { index: 1, value: 50, color: '#EAB839' },
-      { index: 2, value: 62.5, color: '#EF843C' },
-      { index: 3, value: 75, color: '#6ED0E0' },
+      { index: 0, value: -Infinity, color: colors[0] },
+      { index: 1, value: 50, color: colors[2] },
+      { index: 2, value: 62.5, color: colors[4] },
+      { index: 3, value: 75, color: colors[3] },
     ]);
   });
 });
@@ -161,7 +162,7 @@ describe('change threshold value', () => {
 });
 
 describe('on blur threshold value', () => {
-  it.only('should resort rows and update indexes', () => {
+  it('should resort rows and update indexes', () => {
     const { instance } = setup();
     const thresholds = [
       { index: 0, value: -Infinity, color: '#7EB26D' },

+ 7 - 3
packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx

@@ -3,8 +3,8 @@ import { Threshold } from '../../types';
 import { ColorPicker } from '..';
 import { PanelOptionsGroup } from '..';
 import { colors } from '../../utils';
-import { ThemeContext } from '../../themes/ThemeContext';
-import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
+import { ThemeContext } from '../../themes';
+import { getColorFromHexRgbOrName } from '../../utils';
 
 export interface Props {
   thresholds: Threshold[];
@@ -166,7 +166,11 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
         <div className="thresholds-row-input-inner-color">
           {threshold.color && (
             <div className="thresholds-row-input-inner-color-colorpicker">
-              <ColorPicker color={threshold.color} onChange={color => this.onChangeThresholdColor(threshold, color)} />
+              <ColorPicker
+                color={threshold.color}
+                onChange={color => this.onChangeThresholdColor(threshold, color)}
+                enableNamedColors={true}
+              />
             </div>
           )}
         </div>

+ 446 - 3
packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap

@@ -1,7 +1,450 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`Render should render with base threshold 1`] = `
-<ContextConsumer>
-  <Component />
-</ContextConsumer>
+<ThresholdsEditor
+  onChange={
+    [MockFunction] {
+      "calls": Array [
+        Array [
+          Array [
+            Object {
+              "color": "#7EB26D",
+              "index": 0,
+              "value": -Infinity,
+            },
+          ],
+        ],
+      ],
+      "results": Array [
+        Object {
+          "isThrow": false,
+          "value": undefined,
+        },
+      ],
+    }
+  }
+  thresholds={Array []}
+>
+  <Component
+    title="Thresholds"
+  >
+    <div
+      className="panel-options-group"
+    >
+      <div
+        className="panel-options-group__header"
+      >
+        <span
+          className="panel-options-group__title"
+        >
+          Thresholds
+        </span>
+      </div>
+      <div
+        className="panel-options-group__body"
+      >
+        <div
+          className="thresholds"
+        >
+          <div
+            className="thresholds-row"
+            key="0-0"
+          >
+            <div
+              className="thresholds-row-add-button"
+              onClick={[Function]}
+            >
+              <i
+                className="fa fa-plus"
+              />
+            </div>
+            <div
+              className="thresholds-row-color-indicator"
+              style={
+                Object {
+                  "backgroundColor": "#7EB26D",
+                }
+              }
+            />
+            <div
+              className="thresholds-row-input"
+            >
+              <div
+                className="thresholds-row-input-inner"
+              >
+                <span
+                  className="thresholds-row-input-inner-arrow"
+                />
+                <div
+                  className="thresholds-row-input-inner-color"
+                >
+                  <div
+                    className="thresholds-row-input-inner-color-colorpicker"
+                  >
+                    <WithTheme(ColorPicker)
+                      color="#7EB26D"
+                      enableNamedColors={true}
+                      onChange={[Function]}
+                    >
+                      <ColorPicker
+                        color="#7EB26D"
+                        enableNamedColors={true}
+                        onChange={[Function]}
+                        theme={
+                          Object {
+                            "background": Object {
+                              "dropdown": "#1f1f20",
+                              "scrollbar": "#343436",
+                              "scrollbar2": "#343436",
+                            },
+                            "border": Object {
+                              "radius": Object {
+                                "lg": "5px",
+                                "md": "3px",
+                                "sm": "2px",
+                              },
+                              "width": Object {
+                                "sm": "1px",
+                              },
+                            },
+                            "breakpoints": Object {
+                              "lg": "992px",
+                              "md": "768px",
+                              "sm": "544px",
+                              "xl": "1200px",
+                              "xs": "0",
+                            },
+                            "colors": Object {
+                              "black": "#000000",
+                              "blue": "#33b5e5",
+                              "blueBase": "#3274d9",
+                              "blueFaint": "#041126",
+                              "blueLight": "#5794f2",
+                              "blueShade": "#1f60c4",
+                              "body": "#d8d9da",
+                              "bodyBg": "#161719",
+                              "brandDanger": "#e02f44",
+                              "brandPrimary": "#eb7b18",
+                              "brandSuccess": "#299c46",
+                              "brandWarning": "#eb7b18",
+                              "critical": "#e02f44",
+                              "dark1": "#141414",
+                              "dark10": "#424345",
+                              "dark2": "#161719",
+                              "dark3": "#1f1f20",
+                              "dark4": "#212124",
+                              "dark5": "#222426",
+                              "dark6": "#262628",
+                              "dark7": "#292a2d",
+                              "dark8": "#2f2f32",
+                              "dark9": "#343436",
+                              "gray1": "#555555",
+                              "gray2": "#8e8e8e",
+                              "gray3": "#b3b3b3",
+                              "gray4": "#d8d9da",
+                              "gray5": "#ececec",
+                              "gray6": "#f4f5f8",
+                              "gray7": "#fbfbfb",
+                              "grayBlue": "#212327",
+                              "greenBase": "#299c46",
+                              "greenShade": "#23843b",
+                              "headingColor": "#e3e3e3",
+                              "inputBlack": "#09090b",
+                              "link": "#e3e3e3",
+                              "linkDisabled": "#e3e3e3",
+                              "linkExternal": "#33b5e5",
+                              "linkHover": "#ffffff",
+                              "online": "#299c46",
+                              "orange": "#eb7b18",
+                              "pageBg": "#161719",
+                              "purple": "#9933cc",
+                              "queryGreen": "#74e680",
+                              "queryKeyword": "#66d9ef",
+                              "queryOrange": "#eb7b18",
+                              "queryPurple": "#fe85fc",
+                              "queryRed": "#e02f44",
+                              "red": "#d44a3a",
+                              "redBase": "#e02f44",
+                              "redShade": "#c4162a",
+                              "text": "#d8d9da",
+                              "textEmphasis": "#ececec",
+                              "textFaint": "#222426",
+                              "textStrong": "#ffffff",
+                              "textWeak": "#8e8e8e",
+                              "variable": "#32d1df",
+                              "warn": "#f79520",
+                              "white": "#ffffff",
+                              "yellow": "#ecbb13",
+                            },
+                            "name": "Grafana Dark",
+                            "panelPadding": Object {
+                              "horizontal": 10,
+                              "vertical": 5,
+                            },
+                            "spacing": Object {
+                              "d": "14px",
+                              "gutter": "30px",
+                              "lg": "24px",
+                              "md": "16px",
+                              "sm": "8px",
+                              "xl": "32px",
+                              "xs": "4px",
+                              "xxs": "2px",
+                            },
+                            "type": "dark",
+                            "typography": Object {
+                              "fontFamily": Object {
+                                "monospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
+                                "sansSerif": "'Roboto', Helvetica, Arial, sans-serif",
+                              },
+                              "heading": Object {
+                                "h1": "28px",
+                                "h2": "24px",
+                                "h3": "21px",
+                                "h4": "18px",
+                                "h5": "16px",
+                                "h6": "14px",
+                              },
+                              "lineHeight": Object {
+                                "lg": 1.5,
+                                "md": 1.3333333333333333,
+                                "sm": 1.1,
+                                "xs": 1,
+                              },
+                              "size": Object {
+                                "base": "13px",
+                                "lg": "18px",
+                                "md": "14px",
+                                "root": "14px",
+                                "sm": "12px",
+                                "xs": "10px",
+                              },
+                              "weight": Object {
+                                "light": 300,
+                                "regular": 400,
+                                "semibold": 500,
+                              },
+                            },
+                          }
+                        }
+                      >
+                        <PopperController
+                          content={
+                            <ColorPickerPopover
+                              color="#7EB26D"
+                              enableNamedColors={true}
+                              onChange={[Function]}
+                              theme={
+                                Object {
+                                  "background": Object {
+                                    "dropdown": "#1f1f20",
+                                    "scrollbar": "#343436",
+                                    "scrollbar2": "#343436",
+                                  },
+                                  "border": Object {
+                                    "radius": Object {
+                                      "lg": "5px",
+                                      "md": "3px",
+                                      "sm": "2px",
+                                    },
+                                    "width": Object {
+                                      "sm": "1px",
+                                    },
+                                  },
+                                  "breakpoints": Object {
+                                    "lg": "992px",
+                                    "md": "768px",
+                                    "sm": "544px",
+                                    "xl": "1200px",
+                                    "xs": "0",
+                                  },
+                                  "colors": Object {
+                                    "black": "#000000",
+                                    "blue": "#33b5e5",
+                                    "blueBase": "#3274d9",
+                                    "blueFaint": "#041126",
+                                    "blueLight": "#5794f2",
+                                    "blueShade": "#1f60c4",
+                                    "body": "#d8d9da",
+                                    "bodyBg": "#161719",
+                                    "brandDanger": "#e02f44",
+                                    "brandPrimary": "#eb7b18",
+                                    "brandSuccess": "#299c46",
+                                    "brandWarning": "#eb7b18",
+                                    "critical": "#e02f44",
+                                    "dark1": "#141414",
+                                    "dark10": "#424345",
+                                    "dark2": "#161719",
+                                    "dark3": "#1f1f20",
+                                    "dark4": "#212124",
+                                    "dark5": "#222426",
+                                    "dark6": "#262628",
+                                    "dark7": "#292a2d",
+                                    "dark8": "#2f2f32",
+                                    "dark9": "#343436",
+                                    "gray1": "#555555",
+                                    "gray2": "#8e8e8e",
+                                    "gray3": "#b3b3b3",
+                                    "gray4": "#d8d9da",
+                                    "gray5": "#ececec",
+                                    "gray6": "#f4f5f8",
+                                    "gray7": "#fbfbfb",
+                                    "grayBlue": "#212327",
+                                    "greenBase": "#299c46",
+                                    "greenShade": "#23843b",
+                                    "headingColor": "#e3e3e3",
+                                    "inputBlack": "#09090b",
+                                    "link": "#e3e3e3",
+                                    "linkDisabled": "#e3e3e3",
+                                    "linkExternal": "#33b5e5",
+                                    "linkHover": "#ffffff",
+                                    "online": "#299c46",
+                                    "orange": "#eb7b18",
+                                    "pageBg": "#161719",
+                                    "purple": "#9933cc",
+                                    "queryGreen": "#74e680",
+                                    "queryKeyword": "#66d9ef",
+                                    "queryOrange": "#eb7b18",
+                                    "queryPurple": "#fe85fc",
+                                    "queryRed": "#e02f44",
+                                    "red": "#d44a3a",
+                                    "redBase": "#e02f44",
+                                    "redShade": "#c4162a",
+                                    "text": "#d8d9da",
+                                    "textEmphasis": "#ececec",
+                                    "textFaint": "#222426",
+                                    "textStrong": "#ffffff",
+                                    "textWeak": "#8e8e8e",
+                                    "variable": "#32d1df",
+                                    "warn": "#f79520",
+                                    "white": "#ffffff",
+                                    "yellow": "#ecbb13",
+                                  },
+                                  "name": "Grafana Dark",
+                                  "panelPadding": Object {
+                                    "horizontal": 10,
+                                    "vertical": 5,
+                                  },
+                                  "spacing": Object {
+                                    "d": "14px",
+                                    "gutter": "30px",
+                                    "lg": "24px",
+                                    "md": "16px",
+                                    "sm": "8px",
+                                    "xl": "32px",
+                                    "xs": "4px",
+                                    "xxs": "2px",
+                                  },
+                                  "type": "dark",
+                                  "typography": Object {
+                                    "fontFamily": Object {
+                                      "monospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
+                                      "sansSerif": "'Roboto', Helvetica, Arial, sans-serif",
+                                    },
+                                    "heading": Object {
+                                      "h1": "28px",
+                                      "h2": "24px",
+                                      "h3": "21px",
+                                      "h4": "18px",
+                                      "h5": "16px",
+                                      "h6": "14px",
+                                    },
+                                    "lineHeight": Object {
+                                      "lg": 1.5,
+                                      "md": 1.3333333333333333,
+                                      "sm": 1.1,
+                                      "xs": 1,
+                                    },
+                                    "size": Object {
+                                      "base": "13px",
+                                      "lg": "18px",
+                                      "md": "14px",
+                                      "root": "14px",
+                                      "sm": "12px",
+                                      "xs": "10px",
+                                    },
+                                    "weight": Object {
+                                      "light": 300,
+                                      "regular": 400,
+                                      "semibold": 500,
+                                    },
+                                  },
+                                }
+                              }
+                            />
+                          }
+                          hideAfter={300}
+                        >
+                          <ForwardRef(ColorPickerTrigger)
+                            color="#7EB26D"
+                            onClick={[Function]}
+                            onMouseLeave={[Function]}
+                          >
+                            <div
+                              onClick={[Function]}
+                              onMouseLeave={[Function]}
+                              style={
+                                Object {
+                                  "background": "inherit",
+                                  "border": "none",
+                                  "borderRadius": 10,
+                                  "color": "inherit",
+                                  "cursor": "pointer",
+                                  "overflow": "hidden",
+                                  "padding": 0,
+                                }
+                              }
+                            >
+                              <div
+                                style={
+                                  Object {
+                                    "backgroundImage": "url(data:image/png,base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==)",
+                                    "border": "none",
+                                    "float": "left",
+                                    "height": 15,
+                                    "margin": 0,
+                                    "position": "relative",
+                                    "width": 15,
+                                    "zIndex": 0,
+                                  }
+                                }
+                              >
+                                <div
+                                  style={
+                                    Object {
+                                      "backgroundColor": "#7EB26D",
+                                      "bottom": 0,
+                                      "display": "block",
+                                      "left": 0,
+                                      "position": "absolute",
+                                      "right": 0,
+                                      "top": 0,
+                                    }
+                                  }
+                                />
+                              </div>
+                            </div>
+                          </ForwardRef(ColorPickerTrigger)>
+                        </PopperController>
+                      </ColorPicker>
+                    </WithTheme(ColorPicker)>
+                  </div>
+                </div>
+                <div
+                  className="thresholds-row-input-inner-value"
+                >
+                  <input
+                    readOnly={true}
+                    type="text"
+                    value="Base"
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </Component>
+</ThresholdsEditor>
 `;

+ 1 - 0
packages/grafana-ui/src/components/index.ts

@@ -25,6 +25,7 @@ export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
 export { Switch } from './Switch/Switch';
 export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
 export { UnitPicker } from './UnitPicker/UnitPicker';
+export { Input, InputStatus } from './Input/Input';
 
 // Visualizations
 export { Gauge } from './Gauge/Gauge';

+ 1 - 0
packages/grafana-ui/src/types/index.ts

@@ -5,3 +5,4 @@ export * from './plugin';
 export * from './datasource';
 export * from './theme';
 export * from './threshold';
+export * from './input';

+ 0 - 0
public/app/types/form.ts → packages/grafana-ui/src/types/input.ts


+ 1 - 0
packages/grafana-ui/src/types/plugin.ts

@@ -91,6 +91,7 @@ export interface PluginMeta {
   includes: PluginInclude[];
 
   // Datasource-specific
+  builtIn?: boolean;
   metrics?: boolean;
   tables?: boolean;
   logs?: boolean;

+ 1 - 0
packages/grafana-ui/src/utils/index.ts

@@ -8,3 +8,4 @@ export * from './string';
 export * from './displayValue';
 export * from './deprecationWarning';
 export { getMappedValue } from './valueMappings';
+export * from './validate';

+ 24 - 0
packages/grafana-ui/src/utils/validate.ts

@@ -0,0 +1,24 @@
+import { ValidationRule, ValidationEvents } from '../types/input';
+
+export enum EventsWithValidation {
+  onBlur = 'onBlur',
+  onFocus = 'onFocus',
+  onChange = 'onChange',
+}
+
+export const validate = (value: string, validationRules: ValidationRule[]) => {
+  const errors = validationRules.reduce(
+    (acc, currRule) => {
+      if (!currRule.rule(value)) {
+        return acc.concat(currRule.errorMessage);
+      }
+      return acc;
+    },
+    [] as string[]
+  );
+  return errors.length > 0 ? errors : null;
+};
+
+export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents | undefined) => {
+  return validationEvents && validationEvents[event];
+};

+ 14 - 12
pkg/api/api.go

@@ -14,6 +14,7 @@ func (hs *HTTPServer) registerRoutes() {
 	reqGrafanaAdmin := middleware.ReqGrafanaAdmin
 	reqEditorRole := middleware.ReqEditorRole
 	reqOrgAdmin := middleware.ReqOrgAdmin
+	reqCanAccessTeams := middleware.AdminOrFeatureEnabled(hs.Cfg.EditorsCanAdmin)
 	redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL()
 	redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL()
 	quota := middleware.Quota(hs.QuotaService)
@@ -41,8 +42,8 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Get("/org/users", reqOrgAdmin, hs.Index)
 	r.Get("/org/users/new", reqOrgAdmin, hs.Index)
 	r.Get("/org/users/invite", reqOrgAdmin, hs.Index)
-	r.Get("/org/teams", reqOrgAdmin, hs.Index)
-	r.Get("/org/teams/*", reqOrgAdmin, hs.Index)
+	r.Get("/org/teams", reqCanAccessTeams, hs.Index)
+	r.Get("/org/teams/*", reqCanAccessTeams, hs.Index)
 	r.Get("/org/apikeys/", reqOrgAdmin, hs.Index)
 	r.Get("/dashboard/import/", reqSignedIn, hs.Index)
 	r.Get("/configuration", reqGrafanaAdmin, hs.Index)
@@ -153,20 +154,21 @@ func (hs *HTTPServer) registerRoutes() {
 
 		// team (admin permission required)
 		apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
-			teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(CreateTeam))
-			teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(UpdateTeam))
-			teamsRoute.Delete("/:teamId", Wrap(DeleteTeamByID))
+			teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam))
+			teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(hs.UpdateTeam))
+			teamsRoute.Delete("/:teamId", Wrap(hs.DeleteTeamByID))
 			teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers))
-			teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember))
-			teamsRoute.Delete("/:teamId/members/:userId", Wrap(RemoveTeamMember))
-			teamsRoute.Get("/:teamId/preferences", Wrap(GetTeamPreferences))
-			teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateTeamPreferences))
-		}, reqOrgAdmin)
+			teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember))
+			teamsRoute.Put("/:teamId/members/:userId", bind(m.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember))
+			teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember))
+			teamsRoute.Get("/:teamId/preferences", Wrap(hs.GetTeamPreferences))
+			teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(hs.UpdateTeamPreferences))
+		}, reqCanAccessTeams)
 
 		// team without requirement of user to be org admin
 		apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
 			teamsRoute.Get("/:teamId", Wrap(GetTeamByID))
-			teamsRoute.Get("/search", Wrap(SearchTeams))
+			teamsRoute.Get("/search", Wrap(hs.SearchTeams))
 		})
 
 		// org information available to all users.
@@ -265,7 +267,7 @@ func (hs *HTTPServer) registerRoutes() {
 		apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {
 			folderRoute.Get("/", Wrap(GetFolders))
 			folderRoute.Get("/id/:id", Wrap(GetFolderByID))
-			folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(CreateFolder))
+			folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(hs.CreateFolder))
 
 			folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
 				folderUidRoute.Get("/", Wrap(GetFolderByUID))

+ 11 - 1
pkg/api/dashboard.go

@@ -213,7 +213,8 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand)
 
 	dash := cmd.GetDashboardModel()
 
-	if dash.Id == 0 && dash.Uid == "" {
+	newDashboard := dash.Id == 0 && dash.Uid == ""
+	if newDashboard {
 		limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard")
 		if err != nil {
 			return Error(500, "failed to get quota", err)
@@ -276,6 +277,15 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand)
 		return Error(500, "Failed to save dashboard", err)
 	}
 
+	if hs.Cfg.EditorsCanAdmin && newDashboard {
+		inFolder := cmd.FolderId > 0
+		err := dashboards.MakeUserAdmin(hs.Bus, cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder)
+		if err != nil {
+			hs.log.Error("Could not make user admin", "dashboard", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err)
+			return Error(500, "Failed to make user admin of dashboard", err)
+		}
+	}
+
 	c.TimeRequest(metrics.M_Api_Dashboard_Save)
 	return JSON(200, util.DynMap{
 		"status":  "success",

+ 2 - 0
pkg/api/dashboard_test.go

@@ -974,6 +974,7 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d
 
 		hs := HTTPServer{
 			Bus: bus.GetBus(),
+			Cfg: setting.NewCfg(),
 		}
 
 		sc := setupScenarioContext(url)
@@ -1024,6 +1025,7 @@ func restoreDashboardVersionScenario(desc string, url string, routePattern strin
 		defer bus.ClearBusHandlers()
 
 		hs := HTTPServer{
+			Cfg: setting.NewCfg(),
 			Bus: bus.GetBus(),
 		}
 

+ 8 - 1
pkg/api/folder.go

@@ -54,13 +54,20 @@ func GetFolderByID(c *m.ReqContext) Response {
 	return JSON(200, toFolderDto(g, folder))
 }
 
-func CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response {
+func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response {
 	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
 	err := s.CreateFolder(&cmd)
 	if err != nil {
 		return toFolderError(err)
 	}
 
+	if hs.Cfg.EditorsCanAdmin {
+		if err := dashboards.MakeUserAdmin(hs.Bus, c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil {
+			hs.log.Error("Could not make user admin", "folder", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err)
+			return Error(500, "Failed to make user admin of folder", err)
+		}
+	}
+
 	g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser)
 	return JSON(200, toFolderDto(g, cmd.Result))
 }

+ 7 - 1
pkg/api/folder_test.go

@@ -9,6 +9,7 @@ import (
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/dashboards"
+	"github.com/grafana/grafana/pkg/setting"
 
 	. "github.com/smartystreets/goconvey/convey"
 )
@@ -141,12 +142,17 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa
 	Convey(desc+" "+url, func() {
 		defer bus.ClearBusHandlers()
 
+		hs := HTTPServer{
+			Bus: bus.GetBus(),
+			Cfg: setting.NewCfg(),
+		}
+
 		sc := setupScenarioContext(url)
 		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
 
-			return CreateFolder(c, cmd)
+			return hs.CreateFolder(c, cmd)
 		})
 
 		origNewFolderService := dashboards.NewFolderService

+ 1 - 1
pkg/api/frontendsettings.go

@@ -167,7 +167,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
 		"externalUserMngLinkUrl":     setting.ExternalUserMngLinkUrl,
 		"externalUserMngLinkName":    setting.ExternalUserMngLinkName,
 		"viewersCanEdit":             setting.ViewersCanEdit,
-		"editorsCanOwn":              hs.Cfg.EditorsCanOwn,
+		"editorsCanAdmin":            hs.Cfg.EditorsCanAdmin,
 		"disableSanitizeHtml":        hs.Cfg.DisableSanitizeHtml,
 		"buildInfo": map[string]interface{}{
 			"version":       setting.BuildVersion,

+ 21 - 0
pkg/api/index.go

@@ -327,6 +327,27 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
 		})
 	}
 
+	if (c.OrgRole == m.ROLE_EDITOR || c.OrgRole == m.ROLE_VIEWER) && hs.Cfg.EditorsCanAdmin {
+		cfgNode := &dtos.NavLink{
+			Id:       "cfg",
+			Text:     "Configuration",
+			SubTitle: "Organization: " + c.OrgName,
+			Icon:     "gicon gicon-cog",
+			Url:      setting.AppSubUrl + "/org/teams",
+			Children: []*dtos.NavLink{
+				{
+					Text:        "Teams",
+					Id:          "teams",
+					Description: "Manage org groups",
+					Icon:        "gicon gicon-team",
+					Url:         setting.AppSubUrl + "/org/teams",
+				},
+			},
+		}
+
+		data.NavTree = append(data.NavTree, cfgNode)
+	}
+
 	data.NavTree = append(data.NavTree, &dtos.NavLink{
 		Text:         "Help",
 		SubTitle:     fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit),

+ 69 - 17
pkg/api/team.go

@@ -4,19 +4,38 @@ import (
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/teamguardian"
 	"github.com/grafana/grafana/pkg/util"
 )
 
 // POST /api/teams
-func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
+func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
 	cmd.OrgId = c.OrgId
-	if err := bus.Dispatch(&cmd); err != nil {
+
+	if c.OrgRole == m.ROLE_VIEWER {
+		return Error(403, "Not allowed to create team.", nil)
+	}
+
+	if err := hs.Bus.Dispatch(&cmd); err != nil {
 		if err == m.ErrTeamNameTaken {
 			return Error(409, "Team name taken", err)
 		}
 		return Error(500, "Failed to create Team", err)
 	}
 
+	if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanAdmin {
+		addMemberCmd := m.AddTeamMemberCommand{
+			UserId:     c.SignedInUser.UserId,
+			OrgId:      cmd.OrgId,
+			TeamId:     cmd.Result.Id,
+			Permission: m.PERMISSION_ADMIN,
+		}
+
+		if err := hs.Bus.Dispatch(&addMemberCmd); err != nil {
+			c.Logger.Error("Could not add creator to team.", "error", err)
+		}
+	}
+
 	return JSON(200, &util.DynMap{
 		"teamId":  cmd.Result.Id,
 		"message": "Team created",
@@ -24,10 +43,15 @@ func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response {
 }
 
 // PUT /api/teams/:teamId
-func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
+func (hs *HTTPServer) UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
 	cmd.OrgId = c.OrgId
 	cmd.Id = c.ParamsInt64(":teamId")
-	if err := bus.Dispatch(&cmd); err != nil {
+
+	if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.Id, c.SignedInUser); err != nil {
+		return Error(403, "Not allowed to update team", err)
+	}
+
+	if err := hs.Bus.Dispatch(&cmd); err != nil {
 		if err == m.ErrTeamNameTaken {
 			return Error(400, "Team name taken", err)
 		}
@@ -38,18 +62,26 @@ func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response {
 }
 
 // DELETE /api/teams/:teamId
-func DeleteTeamByID(c *m.ReqContext) Response {
-	if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")}); err != nil {
+func (hs *HTTPServer) DeleteTeamByID(c *m.ReqContext) Response {
+	orgId := c.OrgId
+	teamId := c.ParamsInt64(":teamId")
+	user := c.SignedInUser
+
+	if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, user); err != nil {
+		return Error(403, "Not allowed to delete team", err)
+	}
+
+	if err := hs.Bus.Dispatch(&m.DeleteTeamCommand{OrgId: orgId, Id: teamId}); err != nil {
 		if err == m.ErrTeamNotFound {
 			return Error(404, "Failed to delete Team. ID not found", nil)
 		}
-		return Error(500, "Failed to update Team", err)
+		return Error(500, "Failed to delete Team", err)
 	}
 	return Success("Team deleted")
 }
 
 // GET /api/teams/search
-func SearchTeams(c *m.ReqContext) Response {
+func (hs *HTTPServer) SearchTeams(c *m.ReqContext) Response {
 	perPage := c.QueryInt("perpage")
 	if perPage <= 0 {
 		perPage = 1000
@@ -59,12 +91,18 @@ func SearchTeams(c *m.ReqContext) Response {
 		page = 1
 	}
 
+	var userIdFilter int64
+	if hs.Cfg.EditorsCanAdmin && c.OrgRole != m.ROLE_ADMIN {
+		userIdFilter = c.SignedInUser.UserId
+	}
+
 	query := m.SearchTeamsQuery{
-		OrgId: c.OrgId,
-		Query: c.Query("query"),
-		Name:  c.Query("name"),
-		Page:  page,
-		Limit: perPage,
+		OrgId:        c.OrgId,
+		Query:        c.Query("query"),
+		Name:         c.Query("name"),
+		UserIdFilter: userIdFilter,
+		Page:         page,
+		Limit:        perPage,
 	}
 
 	if err := bus.Dispatch(&query); err != nil {
@@ -98,11 +136,25 @@ func GetTeamByID(c *m.ReqContext) Response {
 }
 
 // GET /api/teams/:teamId/preferences
-func GetTeamPreferences(c *m.ReqContext) Response {
-	return getPreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"))
+func (hs *HTTPServer) GetTeamPreferences(c *m.ReqContext) Response {
+	teamId := c.ParamsInt64(":teamId")
+	orgId := c.OrgId
+
+	if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
+		return Error(403, "Not allowed to view team preferences.", err)
+	}
+
+	return getPreferencesFor(orgId, 0, teamId)
 }
 
 // PUT /api/teams/:teamId/preferences
-func UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
-	return updatePreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"), &dtoCmd)
+func (hs *HTTPServer) UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
+	teamId := c.ParamsInt64(":teamId")
+	orgId := c.OrgId
+
+	if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
+		return Error(403, "Not allowed to update team preferences.", err)
+	}
+
+	return updatePreferencesFor(orgId, 0, teamId, &dtoCmd)
 }

+ 49 - 5
pkg/api/team_members.go

@@ -4,6 +4,7 @@ import (
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/teamguardian"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
 )
@@ -29,11 +30,15 @@ func GetTeamMembers(c *m.ReqContext) Response {
 }
 
 // POST /api/teams/:teamId/members
-func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
-	cmd.TeamId = c.ParamsInt64(":teamId")
+func (hs *HTTPServer) AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
 	cmd.OrgId = c.OrgId
+	cmd.TeamId = c.ParamsInt64(":teamId")
 
-	if err := bus.Dispatch(&cmd); err != nil {
+	if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.TeamId, c.SignedInUser); err != nil {
+		return Error(403, "Not allowed to add team member", err)
+	}
+
+	if err := hs.Bus.Dispatch(&cmd); err != nil {
 		if err == m.ErrTeamNotFound {
 			return Error(404, "Team not found", nil)
 		}
@@ -50,9 +55,48 @@ func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response {
 	})
 }
 
+// PUT /:teamId/members/:userId
+func (hs *HTTPServer) UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response {
+	teamId := c.ParamsInt64(":teamId")
+	orgId := c.OrgId
+
+	if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
+		return Error(403, "Not allowed to update team member", err)
+	}
+
+	if c.OrgRole != m.ROLE_ADMIN {
+		cmd.ProtectLastAdmin = true
+	}
+
+	cmd.TeamId = teamId
+	cmd.UserId = c.ParamsInt64(":userId")
+	cmd.OrgId = orgId
+
+	if err := hs.Bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrTeamMemberNotFound {
+			return Error(404, "Team member not found.", nil)
+		}
+		return Error(500, "Failed to update team member.", err)
+	}
+	return Success("Team member updated")
+}
+
 // DELETE /api/teams/:teamId/members/:userId
-func RemoveTeamMember(c *m.ReqContext) Response {
-	if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil {
+func (hs *HTTPServer) RemoveTeamMember(c *m.ReqContext) Response {
+	orgId := c.OrgId
+	teamId := c.ParamsInt64(":teamId")
+	userId := c.ParamsInt64(":userId")
+
+	if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil {
+		return Error(403, "Not allowed to remove team member", err)
+	}
+
+	protectLastAdmin := false
+	if c.OrgRole != m.ROLE_ADMIN {
+		protectLastAdmin = true
+	}
+
+	if err := hs.Bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: orgId, TeamId: teamId, UserId: userId, ProtectLastAdmin: protectLastAdmin}); err != nil {
 		if err == m.ErrTeamNotFound {
 			return Error(404, "Team not found", nil)
 		}

+ 8 - 2
pkg/api/team_test.go

@@ -3,6 +3,8 @@ package api
 import (
 	"testing"
 
+	"github.com/grafana/grafana/pkg/setting"
+
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/models"
@@ -20,6 +22,10 @@ func TestTeamApiEndpoint(t *testing.T) {
 			TotalCount: 2,
 		}
 
+		hs := &HTTPServer{
+			Cfg: setting.NewCfg(),
+		}
+
 		Convey("When searching with no parameters", func() {
 			loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) {
 				var sentLimit int
@@ -33,7 +39,7 @@ func TestTeamApiEndpoint(t *testing.T) {
 					return nil
 				})
 
-				sc.handlerFunc = SearchTeams
+				sc.handlerFunc = hs.SearchTeams
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 				So(sentLimit, ShouldEqual, 1000)
@@ -60,7 +66,7 @@ func TestTeamApiEndpoint(t *testing.T) {
 					return nil
 				})
 
-				sc.handlerFunc = SearchTeams
+				sc.handlerFunc = hs.SearchTeams
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
 
 				So(sentLimit, ShouldEqual, 10)

+ 17 - 0
pkg/middleware/auth.go

@@ -86,3 +86,20 @@ func Auth(options *AuthOptions) macaron.Handler {
 		}
 	}
 }
+
+// AdminOrFeatureEnabled creates a middleware that allows access
+// if the signed in user is either an Org Admin or if the
+// feature flag is enabled.
+// Intended for when feature flags open up access to APIs that
+// are otherwise only available to admins.
+func AdminOrFeatureEnabled(enabled bool) macaron.Handler {
+	return func(c *m.ReqContext) {
+		if c.OrgRole == m.ROLE_ADMIN {
+			return
+		}
+
+		if !enabled {
+			accessForbidden(c)
+		}
+	}
+}

+ 19 - 14
pkg/models/team.go

@@ -7,9 +7,12 @@ import (
 
 // Typed errors
 var (
-	ErrTeamNotFound       = errors.New("Team not found")
-	ErrTeamNameTaken      = errors.New("Team name is taken")
-	ErrTeamMemberNotFound = errors.New("Team member not found")
+	ErrTeamNotFound                         = errors.New("Team not found")
+	ErrTeamNameTaken                        = errors.New("Team name is taken")
+	ErrTeamMemberNotFound                   = errors.New("Team member not found")
+	ErrLastTeamAdmin                        = errors.New("Not allowed to remove last admin")
+	ErrNotAllowedToUpdateTeam               = errors.New("User not allowed to update team")
+	ErrNotAllowedToUpdateTeamInDifferentOrg = errors.New("User not allowed to update team in another org")
 )
 
 // Team model
@@ -59,22 +62,24 @@ type GetTeamsByUserQuery struct {
 }
 
 type SearchTeamsQuery struct {
-	Query string
-	Name  string
-	Limit int
-	Page  int
-	OrgId int64
+	Query        string
+	Name         string
+	Limit        int
+	Page         int
+	OrgId        int64
+	UserIdFilter int64
 
 	Result SearchTeamQueryResult
 }
 
 type TeamDTO struct {
-	Id          int64  `json:"id"`
-	OrgId       int64  `json:"orgId"`
-	Name        string `json:"name"`
-	Email       string `json:"email"`
-	AvatarUrl   string `json:"avatarUrl"`
-	MemberCount int64  `json:"memberCount"`
+	Id          int64          `json:"id"`
+	OrgId       int64          `json:"orgId"`
+	Name        string         `json:"name"`
+	Email       string         `json:"email"`
+	AvatarUrl   string         `json:"avatarUrl"`
+	MemberCount int64          `json:"memberCount"`
+	Permission  PermissionType `json:"permission"`
 }
 
 type SearchTeamQueryResult struct {

+ 32 - 20
pkg/models/team_member.go

@@ -12,11 +12,12 @@ var (
 
 // TeamMember model
 type TeamMember struct {
-	Id       int64
-	OrgId    int64
-	TeamId   int64
-	UserId   int64
-	External bool
+	Id         int64
+	OrgId      int64
+	TeamId     int64
+	UserId     int64
+	External   bool // Signals that the membership has been created by an external systems, such as LDAP
+	Permission PermissionType
 
 	Created time.Time
 	Updated time.Time
@@ -26,16 +27,26 @@ type TeamMember struct {
 // COMMANDS
 
 type AddTeamMemberCommand struct {
-	UserId   int64 `json:"userId" binding:"Required"`
-	OrgId    int64 `json:"-"`
-	TeamId   int64 `json:"-"`
-	External bool  `json:"-"`
+	UserId     int64          `json:"userId" binding:"Required"`
+	OrgId      int64          `json:"-"`
+	TeamId     int64          `json:"-"`
+	External   bool           `json:"-"`
+	Permission PermissionType `json:"-"`
+}
+
+type UpdateTeamMemberCommand struct {
+	UserId           int64          `json:"-"`
+	OrgId            int64          `json:"-"`
+	TeamId           int64          `json:"-"`
+	Permission       PermissionType `json:"permission"`
+	ProtectLastAdmin bool           `json:"-"`
 }
 
 type RemoveTeamMemberCommand struct {
-	OrgId  int64 `json:"-"`
-	UserId int64
-	TeamId int64
+	OrgId            int64 `json:"-"`
+	UserId           int64
+	TeamId           int64
+	ProtectLastAdmin bool `json:"-"`
 }
 
 // ----------------------
@@ -53,12 +64,13 @@ type GetTeamMembersQuery struct {
 // Projections and DTOs
 
 type TeamMemberDTO struct {
-	OrgId     int64    `json:"orgId"`
-	TeamId    int64    `json:"teamId"`
-	UserId    int64    `json:"userId"`
-	External  bool     `json:"-"`
-	Email     string   `json:"email"`
-	Login     string   `json:"login"`
-	AvatarUrl string   `json:"avatarUrl"`
-	Labels    []string `json:"labels"`
+	OrgId      int64          `json:"orgId"`
+	TeamId     int64          `json:"teamId"`
+	UserId     int64          `json:"userId"`
+	External   bool           `json:"-"`
+	Email      string         `json:"email"`
+	Login      string         `json:"login"`
+	AvatarUrl  string         `json:"avatarUrl"`
+	Labels     []string       `json:"labels"`
+	Permission PermissionType `json:"permission"`
 }

+ 55 - 0
pkg/services/dashboards/acl_service.go

@@ -0,0 +1,55 @@
+package dashboards
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/models"
+	"time"
+)
+
+func MakeUserAdmin(bus bus.Bus, orgId int64, userId int64, dashboardId int64, setViewAndEditPermissions bool) error {
+	rtEditor := models.ROLE_EDITOR
+	rtViewer := models.ROLE_VIEWER
+
+	items := []*models.DashboardAcl{
+		{
+			OrgId:       orgId,
+			DashboardId: dashboardId,
+			UserId:      userId,
+			Permission:  models.PERMISSION_ADMIN,
+			Created:     time.Now(),
+			Updated:     time.Now(),
+		},
+	}
+
+	if setViewAndEditPermissions {
+		items = append(items,
+			&models.DashboardAcl{
+				OrgId:       orgId,
+				DashboardId: dashboardId,
+				Role:        &rtEditor,
+				Permission:  models.PERMISSION_EDIT,
+				Created:     time.Now(),
+				Updated:     time.Now(),
+			},
+			&models.DashboardAcl{
+				OrgId:       orgId,
+				DashboardId: dashboardId,
+				Role:        &rtViewer,
+				Permission:  models.PERMISSION_VIEW,
+				Created:     time.Now(),
+				Updated:     time.Now(),
+			},
+		)
+	}
+
+	aclCmd := &models.UpdateDashboardAclCommand{
+		DashboardId: dashboardId,
+		Items:       items,
+	}
+
+	if err := bus.Dispatch(aclCmd); err != nil {
+		return err
+	}
+
+	return nil
+}

+ 4 - 0
pkg/services/sqlstore/migrations/team_mig.go

@@ -54,4 +54,8 @@ func addTeamMigrations(mg *Migrator) {
 	mg.AddMigration("Add column external to team_member table", NewAddColumnMigration(teamMemberV1, &Column{
 		Name: "external", Type: DB_Bool, Nullable: true,
 	}))
+
+	mg.AddMigration("Add column permission to team_member table", NewAddColumnMigration(teamMemberV1, &Column{
+		Name: "permission", Type: DB_SmallInt, Nullable: true,
+	}))
 }

+ 103 - 19
pkg/services/sqlstore/team.go

@@ -18,17 +18,30 @@ func init() {
 	bus.AddHandler("sql", GetTeamsByUser)
 
 	bus.AddHandler("sql", AddTeamMember)
+	bus.AddHandler("sql", UpdateTeamMember)
 	bus.AddHandler("sql", RemoveTeamMember)
 	bus.AddHandler("sql", GetTeamMembers)
 }
 
+func getTeamSearchSqlBase() string {
+	return `SELECT
+		team.id as id,
+		team.org_id,
+		team.name as name,
+		team.email as email,
+		(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count,
+		team_member.permission
+		FROM team as team
+		INNER JOIN team_member on team.id = team_member.team_id AND team_member.user_id = ? `
+}
+
 func getTeamSelectSqlBase() string {
 	return `SELECT
 		team.id as id,
 		team.org_id,
 		team.name as name,
 		team.email as email,
-		(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count
+		(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count 
 		FROM team as team `
 }
 
@@ -91,10 +104,8 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error {
 // DeleteTeam will delete a team, its member and any permissions connected to the team
 func DeleteTeam(cmd *m.DeleteTeamCommand) error {
 	return inTransaction(func(sess *DBSession) error {
-		if teamExists, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil {
+		if _, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil {
 			return err
-		} else if !teamExists {
-			return m.ErrTeamNotFound
 		}
 
 		deletes := []string{
@@ -117,7 +128,7 @@ func teamExists(orgId int64, teamId int64, sess *DBSession) (bool, error) {
 	if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", orgId, teamId); err != nil {
 		return false, err
 	} else if len(res) != 1 {
-		return false, nil
+		return false, m.ErrTeamNotFound
 	}
 
 	return true, nil
@@ -147,7 +158,12 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
 	var sql bytes.Buffer
 	params := make([]interface{}, 0)
 
-	sql.WriteString(getTeamSelectSqlBase())
+	if query.UserIdFilter > 0 {
+		sql.WriteString(getTeamSearchSqlBase())
+		params = append(params, query.UserIdFilter)
+	} else {
+		sql.WriteString(getTeamSelectSqlBase())
+	}
 	sql.WriteString(` WHERE team.org_id = ?`)
 
 	params = append(params, query.OrgId)
@@ -233,19 +249,18 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
 			return m.ErrTeamMemberAlreadyAdded
 		}
 
-		if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
+		if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
 			return err
-		} else if !teamExists {
-			return m.ErrTeamNotFound
 		}
 
 		entity := m.TeamMember{
-			OrgId:    cmd.OrgId,
-			TeamId:   cmd.TeamId,
-			UserId:   cmd.UserId,
-			External: cmd.External,
-			Created:  time.Now(),
-			Updated:  time.Now(),
+			OrgId:      cmd.OrgId,
+			TeamId:     cmd.TeamId,
+			UserId:     cmd.UserId,
+			External:   cmd.External,
+			Created:    time.Now(),
+			Updated:    time.Now(),
+			Permission: cmd.Permission,
 		}
 
 		_, err := sess.Insert(&entity)
@@ -253,13 +268,59 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
 	})
 }
 
+func getTeamMember(sess *DBSession, orgId int64, teamId int64, userId int64) (m.TeamMember, error) {
+	rawSql := `SELECT * FROM team_member WHERE org_id=? and team_id=? and user_id=?`
+	var member m.TeamMember
+	exists, err := sess.SQL(rawSql, orgId, teamId, userId).Get(&member)
+
+	if err != nil {
+		return member, err
+	}
+	if !exists {
+		return member, m.ErrTeamMemberNotFound
+	}
+
+	return member, nil
+}
+
+// UpdateTeamMember updates a team member
+func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error {
+	return inTransaction(func(sess *DBSession) error {
+		member, err := getTeamMember(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
+		if err != nil {
+			return err
+		}
+
+		if cmd.ProtectLastAdmin {
+			_, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
+			if err != nil {
+				return err
+			}
+		}
+
+		if cmd.Permission != m.PERMISSION_ADMIN { // make sure we don't get invalid permission levels in store
+			cmd.Permission = 0
+		}
+
+		member.Permission = cmd.Permission
+		_, err = sess.Cols("permission").Where("org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId).Update(member)
+
+		return err
+	})
+}
+
 // RemoveTeamMember removes a member from a team
 func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
 	return inTransaction(func(sess *DBSession) error {
-		if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
+		if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil {
 			return err
-		} else if !teamExists {
-			return m.ErrTeamNotFound
+		}
+
+		if cmd.ProtectLastAdmin {
+			_, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId)
+			if err != nil {
+				return err
+			}
 		}
 
 		var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?"
@@ -276,6 +337,29 @@ func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
 	})
 }
 
+func isLastAdmin(sess *DBSession, orgId int64, teamId int64, userId int64) (bool, error) {
+	rawSql := "SELECT user_id FROM team_member WHERE org_id=? and team_id=? and permission=?"
+	userIds := []*int64{}
+	err := sess.SQL(rawSql, orgId, teamId, m.PERMISSION_ADMIN).Find(&userIds)
+	if err != nil {
+		return false, err
+	}
+
+	isAdmin := false
+	for _, adminId := range userIds {
+		if userId == *adminId {
+			isAdmin = true
+			break
+		}
+	}
+
+	if isAdmin && len(userIds) == 1 {
+		return true, m.ErrLastTeamAdmin
+	}
+
+	return false, err
+}
+
 // GetTeamMembers return a list of members for the specified team
 func GetTeamMembers(query *m.GetTeamMembersQuery) error {
 	query.Result = make([]*m.TeamMemberDTO, 0)
@@ -293,7 +377,7 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error {
 	if query.External {
 		sess.Where("team_member.external=?", dialect.BooleanStr(true))
 	}
-	sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external")
+	sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external", "team_member.permission")
 	sess.Asc("user.login", "user.email")
 
 	err := sess.Find(&query.Result)

+ 93 - 0
pkg/services/sqlstore/team_test.go

@@ -75,6 +75,72 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 				So(q2.Result[0].External, ShouldEqual, true)
 			})
 
+			Convey("Should be able to update users in a team", func() {
+				userId := userIds[0]
+				team := group1.Result
+				addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userId}
+				err = AddTeamMember(&addMemberCmd)
+				So(err, ShouldBeNil)
+
+				qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
+				err = GetTeamMembers(qBeforeUpdate)
+				So(err, ShouldBeNil)
+				So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0)
+
+				err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
+					UserId:     userId,
+					OrgId:      testOrgId,
+					TeamId:     team.Id,
+					Permission: m.PERMISSION_ADMIN,
+				})
+
+				So(err, ShouldBeNil)
+
+				qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
+				err = GetTeamMembers(qAfterUpdate)
+				So(err, ShouldBeNil)
+				So(qAfterUpdate.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
+			})
+
+			Convey("Should default to member permission level when updating a user with invalid permission level", func() {
+				userID := userIds[0]
+				team := group1.Result
+				addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userID}
+				err = AddTeamMember(&addMemberCmd)
+				So(err, ShouldBeNil)
+
+				qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
+				err = GetTeamMembers(qBeforeUpdate)
+				So(err, ShouldBeNil)
+				So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0)
+
+				invalidPermissionLevel := m.PERMISSION_EDIT
+				err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
+					UserId:     userID,
+					OrgId:      testOrgId,
+					TeamId:     team.Id,
+					Permission: invalidPermissionLevel,
+				})
+
+				So(err, ShouldBeNil)
+
+				qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id}
+				err = GetTeamMembers(qAfterUpdate)
+				So(err, ShouldBeNil)
+				So(qAfterUpdate.Result[0].Permission, ShouldEqual, 0)
+			})
+
+			Convey("Shouldn't be able to update a user not in the team.", func() {
+				err = UpdateTeamMember(&m.UpdateTeamMemberCommand{
+					UserId:     1,
+					OrgId:      testOrgId,
+					TeamId:     group1.Result.Id,
+					Permission: m.PERMISSION_ADMIN,
+				})
+
+				So(err, ShouldEqual, m.ErrTeamMemberNotFound)
+			})
+
 			Convey("Should be able to search for teams", func() {
 				query := &m.SearchTeamsQuery{OrgId: testOrgId, Query: "group", Page: 1}
 				err = SearchTeams(query)
@@ -114,6 +180,33 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 				So(len(q2.Result), ShouldEqual, 0)
 			})
 
+			Convey("When ProtectLastAdmin is set to true", func() {
+				err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: m.PERMISSION_ADMIN})
+				So(err, ShouldBeNil)
+
+				Convey("A user should not be able to remove the last admin", func() {
+					err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true})
+					So(err, ShouldEqual, m.ErrLastTeamAdmin)
+				})
+
+				Convey("A user should be able to remove an admin if there are other admins", func() {
+					AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN})
+					err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true})
+					So(err, ShouldEqual, nil)
+				})
+
+				Convey("A user should not be able to remove the admin permission for the last admin", func() {
+					err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true})
+					So(err, ShouldEqual, m.ErrLastTeamAdmin)
+				})
+
+				Convey("A user should be able to remove the admin permission if there are other admins", func() {
+					AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN})
+					err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true})
+					So(err, ShouldEqual, nil)
+				})
+			})
+
 			Convey("Should be able to remove a group with users and permissions", func() {
 				groupId := group2.Result.Id
 				err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[1]})

+ 34 - 0
pkg/services/teamguardian/team.go

@@ -0,0 +1,34 @@
+package teamguardian
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+func CanAdmin(bus bus.Bus, orgId int64, teamId int64, user *m.SignedInUser) error {
+	if user.OrgRole == m.ROLE_ADMIN {
+		return nil
+	}
+
+	if user.OrgId != orgId {
+		return m.ErrNotAllowedToUpdateTeamInDifferentOrg
+	}
+
+	cmd := m.GetTeamMembersQuery{
+		OrgId:  orgId,
+		TeamId: teamId,
+		UserId: user.UserId,
+	}
+
+	if err := bus.Dispatch(&cmd); err != nil {
+		return err
+	}
+
+	for _, member := range cmd.Result {
+		if member.UserId == user.UserId && member.Permission == m.PERMISSION_ADMIN {
+			return nil
+		}
+	}
+
+	return m.ErrNotAllowedToUpdateTeam
+}

+ 87 - 0
pkg/services/teamguardian/teams_test.go

@@ -0,0 +1,87 @@
+package teamguardian
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	. "github.com/smartystreets/goconvey/convey"
+	"testing"
+)
+
+func TestUpdateTeam(t *testing.T) {
+	Convey("Updating a team", t, func() {
+		bus.ClearBusHandlers()
+
+		admin := m.SignedInUser{
+			UserId:  1,
+			OrgId:   1,
+			OrgRole: m.ROLE_ADMIN,
+		}
+		editor := m.SignedInUser{
+			UserId:  2,
+			OrgId:   1,
+			OrgRole: m.ROLE_EDITOR,
+		}
+		testTeam := m.Team{
+			Id:    1,
+			OrgId: 1,
+		}
+
+		Convey("Given an editor and a team he isn't a member of", func() {
+			Convey("Should not be able to update the team", func() {
+				bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
+					cmd.Result = []*m.TeamMemberDTO{}
+					return nil
+				})
+
+				err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor)
+				So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam)
+			})
+		})
+
+		Convey("Given an editor and a team he is an admin in", func() {
+			Convey("Should be able to update the team", func() {
+				bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
+					cmd.Result = []*m.TeamMemberDTO{{
+						OrgId:      testTeam.OrgId,
+						TeamId:     testTeam.Id,
+						UserId:     editor.UserId,
+						Permission: m.PERMISSION_ADMIN,
+					}}
+					return nil
+				})
+
+				err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor)
+				So(err, ShouldBeNil)
+			})
+		})
+
+		Convey("Given an editor and a team in another org", func() {
+			testTeamOtherOrg := m.Team{
+				Id:    1,
+				OrgId: 2,
+			}
+
+			Convey("Shouldn't be able to update the team", func() {
+				bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error {
+					cmd.Result = []*m.TeamMemberDTO{{
+						OrgId:      testTeamOtherOrg.OrgId,
+						TeamId:     testTeamOtherOrg.Id,
+						UserId:     editor.UserId,
+						Permission: m.PERMISSION_ADMIN,
+					}}
+					return nil
+				})
+
+				err := CanAdmin(bus.GetBus(), testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor)
+				So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg)
+			})
+		})
+
+		Convey("Given an org admin and a team", func() {
+			Convey("Should be able to update the team", func() {
+				err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &admin)
+				So(err, ShouldBeNil)
+			})
+		})
+	})
+}

+ 3 - 4
pkg/setting/setting.go

@@ -239,14 +239,13 @@ type Cfg struct {
 	LoginMaxLifetimeDays         int
 	TokenRotationIntervalMinutes int
 
-	// User
-	EditorsCanOwn bool
-
 	// Dataproxy
 	SendUserHeader bool
 
 	// DistributedCache
 	RemoteCacheOptions *RemoteCacheOptions
+
+	EditorsCanAdmin bool
 }
 
 type CommandLineArgs struct {
@@ -670,7 +669,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	ExternalUserMngLinkName = users.Key("external_manage_link_name").String()
 	ExternalUserMngInfo = users.Key("external_manage_info").String()
 	ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false)
-	cfg.EditorsCanOwn = users.Key("editors_can_own").MustBool(false)
+	cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false)
 
 	// auth
 	auth := iniFile.Section("auth")

+ 0 - 1
public/app/core/components/Form/index.ts

@@ -1 +0,0 @@
-export { Input } from './Input';

+ 13 - 0
public/app/core/components/WithFeatureToggle.tsx

@@ -0,0 +1,13 @@
+import React, { FunctionComponent } from 'react';
+
+export interface Props {
+  featureToggle: boolean;
+}
+
+export const WithFeatureToggle: FunctionComponent<Props> = ({ featureToggle, children }) => {
+  if (featureToggle === true) {
+    return <>{children}</>;
+  }
+
+  return null;
+};

+ 1 - 0
public/app/core/components/sidemenu/BottomNavLinks.test.tsx

@@ -12,6 +12,7 @@ const setup = (propOverrides?: object) => {
     {
       link: {},
       user: {
+        id: 1,
         isGrafanaAdmin: false,
         isSignedIn: false,
         orgCount: 2,

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

@@ -13,7 +13,7 @@ export interface BuildInfo {
 
 export class Settings {
   datasources: any;
-  panels: PanelPlugin[];
+  panels: { [key: string]: PanelPlugin };
   appSubUrl: string;
   windowTitlePrefix: string;
   buildInfo: BuildInfo;
@@ -37,7 +37,7 @@ export class Settings {
   passwordHint: any;
   loginError: any;
   viewersCanEdit: boolean;
-  editorsCanOwn: boolean;
+  editorsCanAdmin: boolean;
   disableSanitizeHtml: boolean;
   theme: GrafanaTheme;
 
@@ -59,7 +59,7 @@ export class Settings {
         isEnterprise: false,
       },
       viewersCanEdit: false,
-      editorsCanOwn: false,
+      editorsCanAdmin: false,
       disableSanitizeHtml: false,
     };
 

+ 1 - 0
public/app/core/services/context_srv.ts

@@ -3,6 +3,7 @@ import _ from 'lodash';
 import coreModule from 'app/core/core_module';
 
 export class User {
+  id: number;
   isGrafanaAdmin: any;
   isSignedIn: any;
   orgRole: any;

+ 5 - 8
public/app/core/utils/explore.ts

@@ -9,6 +9,7 @@ import store from 'app/core/store';
 import { parse as parseDate } from 'app/core/utils/datemath';
 import { colors } from '@grafana/ui';
 import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
+import { getNextRefIdChar } from './query';
 
 // Types
 import { RawTimeRange, IntervalValues, DataQuery, DataSourceApi } from '@grafana/ui';
@@ -225,12 +226,8 @@ export function generateKey(index = 0): string {
   return `Q-${Date.now()}-${Math.random()}-${index}`;
 }
 
-export function generateRefId(index = 0): string {
-  return `${index + 1}`;
-}
-
-export function generateEmptyQuery(index = 0): { refId: string; key: string } {
-  return { refId: generateRefId(index), key: generateKey(index) };
+export function generateEmptyQuery(queries: DataQuery[], index = 0): DataQuery {
+  return { refId: getNextRefIdChar(queries), key: generateKey(index) };
 }
 
 /**
@@ -238,9 +235,9 @@ export function generateEmptyQuery(index = 0): { refId: string; key: string } {
  */
 export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
   if (queries && typeof queries === 'object' && queries.length > 0) {
-    return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(i) }));
+    return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(queries, i) }));
   }
-  return [{ ...generateEmptyQuery() }];
+  return [{ ...generateEmptyQuery(queries) }];
 }
 
 /**

+ 30 - 0
public/app/core/utils/query.test.ts

@@ -0,0 +1,30 @@
+import { DataQuery } from '@grafana/ui';
+import { getNextRefIdChar } from './query';
+
+const dataQueries: DataQuery[] = [
+  {
+    refId: 'A',
+  },
+  {
+    refId: 'B',
+  },
+  {
+    refId: 'C',
+  },
+  {
+    refId: 'D',
+  },
+  {
+    refId: 'E',
+  },
+];
+
+describe('Get next refId char', () => {
+  it('should return next char', () => {
+    expect(getNextRefIdChar(dataQueries)).toEqual('F');
+  });
+
+  it('should get first char', () => {
+    expect(getNextRefIdChar([])).toEqual('A');
+  });
+});

+ 12 - 0
public/app/core/utils/query.ts

@@ -0,0 +1,12 @@
+import _ from 'lodash';
+import { DataQuery } from '@grafana/ui/';
+
+export const getNextRefIdChar = (queries: DataQuery[]): string => {
+  const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+  return _.find(letters, refId => {
+    return _.every(queries, other => {
+      return other.refId !== refId;
+    });
+  });
+};

+ 0 - 16
public/app/core/utils/validate.ts

@@ -1,16 +0,0 @@
-import { ValidationRule, ValidationEvents } from 'app/types';
-import { EventsWithValidation } from 'app/core/components/Form/Input';
-
-export const validate = (value: string, validationRules: ValidationRule[]) => {
-  const errors = validationRules.reduce((acc, currRule) => {
-    if (!currRule.rule(value)) {
-      return acc.concat(currRule.errorMessage);
-    }
-    return acc;
-  }, []);
-  return errors.length > 0 ? errors : null;
-};
-
-export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents) => {
-  return validationEvents && validationEvents[event];
-};

+ 10 - 7
public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts

@@ -4,13 +4,16 @@ jest.mock('app/core/store', () => {
   };
 });
 
+// @ts-ignore
 import _ from 'lodash';
 import config from 'app/core/config';
 import { DashboardExporter } from './DashboardExporter';
 import { DashboardModel } from '../../state/DashboardModel';
+import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { PanelPlugin } from 'app/types';
 
 describe('given dashboard with repeated panels', () => {
-  let dash, exported;
+  let dash: any, exported: any;
 
   beforeEach(done => {
     dash = {
@@ -89,25 +92,25 @@ describe('given dashboard with repeated panels', () => {
     config.buildInfo.version = '3.0.2';
 
     //Stubs test function calls
-    const datasourceSrvStub = { get: jest.fn(arg => getStub(arg)) };
+    const datasourceSrvStub = ({ get: jest.fn(arg => getStub(arg)) } as any) as DatasourceSrv;
 
     config.panels['graph'] = {
       id: 'graph',
       name: 'Graph',
       info: { version: '1.1.0' },
-    };
+    } as PanelPlugin;
 
     config.panels['table'] = {
       id: 'table',
       name: 'Table',
       info: { version: '1.1.1' },
-    };
+    } as PanelPlugin;
 
     config.panels['heatmap'] = {
       id: 'heatmap',
       name: 'Heatmap',
       info: { version: '1.1.2' },
-    };
+    } as PanelPlugin;
 
     dash = new DashboardModel(dash, {});
     const exporter = new DashboardExporter(datasourceSrvStub);
@@ -213,7 +216,7 @@ describe('given dashboard with repeated panels', () => {
 });
 
 // Stub responses
-const stubs = [];
+const stubs: { [key: string]: {} } = {};
 stubs['gfdb'] = {
   name: 'gfdb',
   meta: { id: 'testdb', info: { version: '1.2.1' }, name: 'TestDB' },
@@ -249,6 +252,6 @@ stubs['-- Grafana --'] = {
   },
 };
 
-function getStub(arg) {
+function getStub(arg: string) {
   return Promise.resolve(stubs[arg || 'gfdb']);
 }

+ 47 - 14
public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts

@@ -1,9 +1,42 @@
-import config from 'app/core/config';
+// @ts-ignore
 import _ from 'lodash';
+
+import config from 'app/core/config';
 import { DashboardModel } from '../../state/DashboardModel';
+import DatasourceSrv from 'app/features/plugins/datasource_srv';
+import { PanelModel } from 'app/features/dashboard/state';
+import { PanelPlugin } from 'app/types/plugins';
+
+interface Input {
+  name: string;
+  type: string;
+  label: string;
+  value: any;
+  description: string;
+}
+
+interface Requires {
+  [key: string]: {
+    type: string;
+    id: string;
+    name: string;
+    version: string;
+  };
+}
+
+interface DataSources {
+  [key: string]: {
+    name: string;
+    label: string;
+    description: string;
+    type: string;
+    pluginId: string;
+    pluginName: string;
+  };
+}
 
 export class DashboardExporter {
-  constructor(private datasourceSrv) {}
+  constructor(private datasourceSrv: DatasourceSrv) {}
 
   makeExportable(dashboard: DashboardModel) {
     // clean up repeated rows and panels,
@@ -18,19 +51,19 @@ export class DashboardExporter {
     // undo repeat cleanup
     dashboard.processRepeats();
 
-    const inputs = [];
-    const requires = {};
-    const datasources = {};
-    const promises = [];
-    const variableLookup: any = {};
+    const inputs: Input[] = [];
+    const requires: Requires = {};
+    const datasources: DataSources = {};
+    const promises: Array<Promise<void>> = [];
+    const variableLookup: { [key: string]: any } = {};
 
     for (const variable of saveModel.templating.list) {
       variableLookup[variable.name] = variable;
     }
 
-    const templateizeDatasourceUsage = obj => {
-      let datasource = obj.datasource;
-      let datasourceVariable = null;
+    const templateizeDatasourceUsage = (obj: any) => {
+      let datasource: string = obj.datasource;
+      let datasourceVariable: any = null;
 
       // ignore data source properties that contain a variable
       if (datasource && datasource.indexOf('$') === 0) {
@@ -74,7 +107,7 @@ export class DashboardExporter {
       );
     };
 
-    const processPanel = panel => {
+    const processPanel = (panel: PanelModel) => {
       if (panel.datasource !== undefined) {
         templateizeDatasourceUsage(panel);
       }
@@ -87,7 +120,7 @@ export class DashboardExporter {
         }
       }
 
-      const panelDef = config.panels[panel.type];
+      const panelDef: PanelPlugin = config.panels[panel.type];
       if (panelDef) {
         requires['panel' + panelDef.id] = {
           type: 'panel',
@@ -135,7 +168,7 @@ export class DashboardExporter {
 
     return Promise.all(promises)
       .then(() => {
-        _.each(datasources, (value, key) => {
+        _.each(datasources, (value: any) => {
           inputs.push(value);
         });
 
@@ -160,7 +193,7 @@ export class DashboardExporter {
         }
 
         // make inputs and requires a top thing
-        const newObj = {};
+        const newObj: { [key: string]: {} } = {};
         newObj['__inputs'] = inputs;
         newObj['__requires'] = _.sortBy(requires, ['id']);
 

+ 2 - 7
public/app/features/dashboard/panel_editor/QueryOptions.tsx

@@ -5,17 +5,12 @@ import React, { PureComponent, ChangeEvent, FocusEvent } from 'react';
 import { isValidTimeSpan } from 'app/core/utils/rangeutil';
 
 // Components
-import { Switch } from '@grafana/ui';
-import { Input } from 'app/core/components/Form';
-import { EventsWithValidation } from 'app/core/components/Form/Input';
-import { InputStatus } from 'app/core/components/Form/Input';
+import { DataSourceSelectItem, EventsWithValidation, Input, InputStatus, Switch, ValidationEvents } from '@grafana/ui';
 import { DataSourceOption } from './DataSourceOption';
 import { FormLabel } from '@grafana/ui';
 
 // Types
-import { PanelModel } from '../state/PanelModel';
-import { DataSourceSelectItem } from '@grafana/ui/src/types';
-import { ValidationEvents } from 'app/types';
+import { PanelModel } from '../state';
 
 const timeRangeValidationEvents: ValidationEvents = {
   [EventsWithValidation.onBlur]: [

+ 13 - 2
public/app/features/dashboard/state/PanelModel.test.ts

@@ -3,9 +3,10 @@ import { PanelModel } from './PanelModel';
 describe('PanelModel', () => {
   describe('when creating new panel model', () => {
     let model;
+    let modelJson;
 
     beforeEach(() => {
-      model = new PanelModel({
+      modelJson = {
         type: 'table',
         showColumns: true,
         targets: [{ refId: 'A' }, { noRefId: true }],
@@ -23,7 +24,8 @@ describe('PanelModel', () => {
             },
           ],
         },
-      });
+      };
+      model = new PanelModel(modelJson);
     });
 
     it('should apply defaults', () => {
@@ -38,6 +40,15 @@ describe('PanelModel', () => {
       expect(model.targets[1].refId).toBe('B');
     });
 
+    it("shouldn't break panel with non-array targets", () => {
+      modelJson.targets = {
+        0: { refId: 'A' },
+        foo: { bar: 'baz' },
+      };
+      model = new PanelModel(modelJson);
+      expect(model.targets[0].refId).toBe('A');
+    });
+
     it('getSaveModel should remove defaults', () => {
       const saveModel = model.getSaveModel();
       expect(saveModel.gridPos).toBe(undefined);

+ 7 - 14
public/app/features/dashboard/state/PanelModel.ts

@@ -1,8 +1,11 @@
 // Libraries
 import _ from 'lodash';
 
-// Types
+// Utils
 import { Emitter } from 'app/core/utils/emitter';
+import { getNextRefIdChar } from 'app/core/utils/query';
+
+// Types
 import { DataQuery, TimeSeries, Threshold, ScopedVars, PanelTypeChangedHook } from '@grafana/ui';
 import { TableData } from '@grafana/ui/src';
 
@@ -125,10 +128,10 @@ export class PanelModel {
   }
 
   ensureQueryIds() {
-    if (this.targets) {
+    if (this.targets && _.isArray(this.targets)) {
       for (const query of this.targets) {
         if (!query.refId) {
-          query.refId = this.getNextQueryLetter();
+          query.refId = getNextRefIdChar(this.targets);
         }
       }
     }
@@ -266,20 +269,10 @@ export class PanelModel {
 
   addQuery(query?: Partial<DataQuery>) {
     query = query || { refId: 'A' };
-    query.refId = this.getNextQueryLetter();
+    query.refId = getNextRefIdChar(this.targets);
     this.targets.push(query as DataQuery);
   }
 
-  getNextQueryLetter(): string {
-    const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
-
-    return _.find(letters, refId => {
-      return _.every(this.targets, other => {
-        return other.refId !== refId;
-      });
-    });
-  }
-
   changeQuery(query: DataQuery, index: number) {
     // ensure refId is maintained
     query.refId = this.targets[index].refId;

+ 11 - 9
public/app/features/explore/state/actions.ts

@@ -60,7 +60,6 @@ import {
   splitCloseAction,
   splitOpenAction,
   addQueryRowAction,
-  AddQueryRowPayload,
   toggleGraphAction,
   toggleLogsAction,
   toggleTableAction,
@@ -87,9 +86,12 @@ const updateExploreUIState = (exploreId, uiStateFragment: Partial<ExploreUIState
 /**
  * Adds a query row after the row with the given index.
  */
-export function addQueryRow(exploreId: ExploreId, index: number): ActionOf<AddQueryRowPayload> {
-  const query = generateEmptyQuery(index + 1);
-  return addQueryRowAction({ exploreId, index, query });
+export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<void> {
+  return (dispatch, getState) => {
+    const query = generateEmptyQuery(getState().explore[exploreId].queries, index);
+
+    dispatch(addQueryRowAction({ exploreId, index, query }));
+  };
 }
 
 /**
@@ -126,10 +128,10 @@ export function changeQuery(
   index: number,
   override: boolean
 ): ThunkResult<void> {
-  return dispatch => {
+  return (dispatch, getState) => {
     // Null query means reset
     if (query === null) {
-      query = { ...generateEmptyQuery(index) };
+      query = { ...generateEmptyQuery(getState().explore[exploreId].queries) };
     }
 
     dispatch(changeQueryAction({ exploreId, query, index, override }));
@@ -287,7 +289,7 @@ export function importQueries(
 
     const nextQueries = importedQueries.map((q, i) => ({
       ...q,
-      ...generateEmptyQuery(i),
+      ...generateEmptyQuery(queries),
     }));
 
     dispatch(queriesImportedAction({ exploreId, queries: nextQueries }));
@@ -629,9 +631,9 @@ export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkRes
  * Use this action for clicks on query examples. Triggers a query run.
  */
 export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult<void> {
-  return dispatch => {
+  return (dispatch, getState) => {
     // Inject react keys into query objects
-    const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery() }));
+    const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery(getState().explore[exploreId].queries) }));
     dispatch(setQueriesAction({ exploreId, queries }));
     dispatch(runQueries(exploreId));
   };

+ 5 - 3
public/app/features/explore/state/reducers.ts

@@ -127,7 +127,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       const { query, index } = action.payload;
 
       // Override path: queries are completely reset
-      const nextQuery: DataQuery = { ...query, ...generateEmptyQuery(index) };
+      const nextQuery: DataQuery = { ...query, ...generateEmptyQuery(state.queries) };
       const nextQueries = [...queries];
       nextQueries[index] = nextQuery;
 
@@ -267,7 +267,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
         // Modify all queries
         nextQueries = queries.map((query, i) => ({
           ...modifier({ ...query }, modification),
-          ...generateEmptyQuery(i),
+          ...generateEmptyQuery(state.queries),
         }));
         // Discard all ongoing transactions
         nextQueryTransactions = [];
@@ -276,7 +276,9 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
         nextQueries = queries.map((query, i) => {
           // Synchronize all queries with local query cache to ensure consistency
           // TODO still needed?
-          return i === index ? { ...modifier({ ...query }, modification), ...generateEmptyQuery(i) } : query;
+          return i === index
+            ? { ...modifier({ ...query }, modification), ...generateEmptyQuery(state.queries) }
+            : query;
         });
         nextQueryTransactions = queryTransactions
           // Consume the hint corresponding to the action

+ 43 - 1
public/app/features/teams/TeamList.test.tsx

@@ -1,8 +1,9 @@
 import React from 'react';
 import { shallow } from 'enzyme';
 import { Props, TeamList } from './TeamList';
-import { NavModel, Team } from '../../types';
+import { NavModel, Team, OrgRole } from '../../types';
 import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks';
+import { User } from 'app/core/services/context_srv';
 
 const setup = (propOverrides?: object) => {
   const props: Props = {
@@ -21,6 +22,11 @@ const setup = (propOverrides?: object) => {
     searchQuery: '',
     teamsCount: 0,
     hasFetched: false,
+    editorsCanAdmin: false,
+    signedInUser: {
+      id: 1,
+      orgRole: OrgRole.Viewer,
+    } as User,
   };
 
   Object.assign(props, propOverrides);
@@ -49,6 +55,42 @@ describe('Render', () => {
 
     expect(wrapper).toMatchSnapshot();
   });
+
+  describe('when feature toggle editorsCanAdmin is turned on', () => {
+    describe('and signedin user is not viewer', () => {
+      it('should enable the new team button', () => {
+        const { wrapper } = setup({
+          teams: getMultipleMockTeams(1),
+          teamsCount: 1,
+          hasFetched: true,
+          editorsCanAdmin: true,
+          signedInUser: {
+            id: 1,
+            orgRole: OrgRole.Editor,
+          } as User,
+        });
+
+        expect(wrapper).toMatchSnapshot();
+      });
+    });
+
+    describe('and signedin user is a viewer', () => {
+      it('should disable the new team button', () => {
+        const { wrapper } = setup({
+          teams: getMultipleMockTeams(1),
+          teamsCount: 1,
+          hasFetched: true,
+          editorsCanAdmin: true,
+          signedInUser: {
+            id: 1,
+            orgRole: OrgRole.Viewer,
+          } as User,
+        });
+
+        expect(wrapper).toMatchSnapshot();
+      });
+    });
+  });
 });
 
 describe('Life cycle', () => {

+ 17 - 5
public/app/features/teams/TeamList.tsx

@@ -4,11 +4,13 @@ import { hot } from 'react-hot-loader';
 import Page from 'app/core/components/Page/Page';
 import { DeleteButton } from '@grafana/ui';
 import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
-import { NavModel, Team } from 'app/types';
+import { NavModel, Team, OrgRole } from 'app/types';
 import { loadTeams, deleteTeam, setSearchQuery } from './state/actions';
-import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors';
+import { getSearchQuery, getTeams, getTeamsCount, isPermissionTeamAdmin } from './state/selectors';
 import { getNavModel } from 'app/core/selectors/navModel';
 import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
+import { config } from 'app/core/config';
+import { contextSrv, User } from 'app/core/services/context_srv';
 
 export interface Props {
   navModel: NavModel;
@@ -19,6 +21,8 @@ export interface Props {
   loadTeams: typeof loadTeams;
   deleteTeam: typeof deleteTeam;
   setSearchQuery: typeof setSearchQuery;
+  editorsCanAdmin?: boolean;
+  signedInUser?: User;
 }
 
 export class TeamList extends PureComponent<Props, any> {
@@ -39,7 +43,10 @@ export class TeamList extends PureComponent<Props, any> {
   };
 
   renderTeam(team: Team) {
+    const { editorsCanAdmin, signedInUser } = this.props;
+    const permission = team.permission;
     const teamUrl = `org/teams/edit/${team.id}`;
+    const canDelete = isPermissionTeamAdmin({ permission, editorsCanAdmin, signedInUser });
 
     return (
       <tr key={team.id}>
@@ -58,7 +65,7 @@ export class TeamList extends PureComponent<Props, any> {
           <a href={teamUrl}>{team.memberCount}</a>
         </td>
         <td className="text-right">
-          <DeleteButton onConfirm={() => this.deleteTeam(team)} />
+          <DeleteButton onConfirm={() => this.deleteTeam(team)} disabled={!canDelete} />
         </td>
       </tr>
     );
@@ -84,7 +91,10 @@ export class TeamList extends PureComponent<Props, any> {
   }
 
   renderTeamList() {
-    const { teams, searchQuery } = this.props;
+    const { teams, searchQuery, editorsCanAdmin, signedInUser } = this.props;
+    const isCanAdminAndViewer = editorsCanAdmin && signedInUser.orgRole === OrgRole.Viewer;
+    const disabledClass = isCanAdminAndViewer ? ' disabled' : '';
+    const newTeamHref = isCanAdminAndViewer ? '#' : 'org/teams/new';
 
     return (
       <>
@@ -101,7 +111,7 @@ export class TeamList extends PureComponent<Props, any> {
 
           <div className="page-action-bar__spacer" />
 
-          <a className="btn btn-primary" href="org/teams/new">
+          <a className={`btn btn-primary${disabledClass}`} href={newTeamHref}>
             New team
           </a>
         </div>
@@ -152,6 +162,8 @@ function mapStateToProps(state) {
     searchQuery: getSearchQuery(state.teams),
     teamsCount: getTeamsCount(state.teams),
     hasFetched: state.teams.hasFetched,
+    editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
+    signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
   };
 }
 

+ 90 - 0
public/app/features/teams/TeamMemberRow.test.tsx

@@ -0,0 +1,90 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { TeamMember, TeamPermissionLevel } from '../../types';
+import { getMockTeamMember } from './__mocks__/teamMocks';
+import { TeamMemberRow, Props } from './TeamMemberRow';
+import { SelectOptionItem } from '@grafana/ui';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    member: getMockTeamMember(),
+    syncEnabled: false,
+    editorsCanAdmin: false,
+    signedInUserIsTeamAdmin: false,
+    updateTeamMember: jest.fn(),
+    removeTeamMember: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<TeamMemberRow {...props} />);
+  const instance = wrapper.instance() as TeamMemberRow;
+
+  return {
+    wrapper,
+    instance,
+  };
+};
+
+describe('Render', () => {
+  it('should render team members when sync enabled', () => {
+    const member = getMockTeamMember();
+    member.labels = ['LDAP'];
+    const { wrapper } = setup({ member, syncEnabled: true });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  describe('when feature toggle editorsCanAdmin is turned on', () => {
+    it('should render permissions select if user is team admin', () => {
+      const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: true });
+
+      expect(wrapper).toMatchSnapshot();
+    });
+
+    it('should render span and disable buttons if user is team member', () => {
+      const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: false });
+
+      expect(wrapper).toMatchSnapshot();
+    });
+  });
+
+  describe('when feature toggle editorsCanAdmin is turned off', () => {
+    it('should not render permissions', () => {
+      const { wrapper } = setup({ editorsCanAdmin: false, signedInUserIsTeamAdmin: true });
+
+      expect(wrapper).toMatchSnapshot();
+    });
+  });
+});
+
+describe('Functions', () => {
+  describe('on remove member', () => {
+    const member = getMockTeamMember();
+    const { instance } = setup({ member });
+
+    instance.onRemoveMember(member);
+
+    expect(instance.props.removeTeamMember).toHaveBeenCalledWith(1);
+  });
+
+  describe('on update permision for user in team', () => {
+    const member: TeamMember = {
+      userId: 3,
+      teamId: 2,
+      avatarUrl: '',
+      email: 'user@user.org',
+      labels: [],
+      login: 'member',
+      permission: TeamPermissionLevel.Member,
+    };
+    const { instance } = setup({ member });
+    const permission = TeamPermissionLevel.Admin;
+    const item: SelectOptionItem = { value: permission };
+    const expectedTeamMemeber = { ...member, permission };
+
+    instance.onPermissionChange(item, member);
+
+    expect(instance.props.updateTeamMember).toHaveBeenCalledWith(expectedTeamMemeber);
+  });
+});

+ 106 - 0
public/app/features/teams/TeamMemberRow.tsx

@@ -0,0 +1,106 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import { DeleteButton, Select, SelectOptionItem } from '@grafana/ui';
+
+import { TeamMember, teamsPermissionLevels } from 'app/types';
+import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle';
+import { updateTeamMember, removeTeamMember } from './state/actions';
+import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
+
+export interface Props {
+  member: TeamMember;
+  syncEnabled: boolean;
+  editorsCanAdmin: boolean;
+  signedInUserIsTeamAdmin: boolean;
+  removeTeamMember?: typeof removeTeamMember;
+  updateTeamMember?: typeof updateTeamMember;
+}
+
+export class TeamMemberRow extends PureComponent<Props> {
+  constructor(props: Props) {
+    super(props);
+    this.renderLabels = this.renderLabels.bind(this);
+    this.renderPermissions = this.renderPermissions.bind(this);
+  }
+
+  onRemoveMember(member: TeamMember) {
+    this.props.removeTeamMember(member.userId);
+  }
+
+  onPermissionChange = (item: SelectOptionItem, member: TeamMember) => {
+    const permission = item.value;
+    const updatedTeamMember = { ...member, permission };
+
+    this.props.updateTeamMember(updatedTeamMember);
+  };
+
+  renderPermissions(member: TeamMember) {
+    const { editorsCanAdmin, signedInUserIsTeamAdmin } = this.props;
+    const value = teamsPermissionLevels.find(dp => dp.value === member.permission);
+
+    return (
+      <WithFeatureToggle featureToggle={editorsCanAdmin}>
+        <td className="width-5 team-permissions">
+          <div className="gf-form">
+            {signedInUserIsTeamAdmin && (
+              <Select
+                isSearchable={false}
+                options={teamsPermissionLevels}
+                onChange={item => this.onPermissionChange(item, member)}
+                className="gf-form-select-box__control--menu-right"
+                value={value}
+              />
+            )}
+            {!signedInUserIsTeamAdmin && <span>{value.label}</span>}
+          </div>
+        </td>
+      </WithFeatureToggle>
+    );
+  }
+
+  renderLabels(labels: string[]) {
+    if (!labels) {
+      return <td />;
+    }
+
+    return (
+      <td>
+        {labels.map(label => (
+          <TagBadge key={label} label={label} removeIcon={false} count={0} onClick={() => {}} />
+        ))}
+      </td>
+    );
+  }
+
+  render() {
+    const { member, syncEnabled, signedInUserIsTeamAdmin } = this.props;
+    return (
+      <tr key={member.userId}>
+        <td className="width-4 text-center">
+          <img className="filter-table__avatar" src={member.avatarUrl} />
+        </td>
+        <td>{member.login}</td>
+        <td>{member.email}</td>
+        {this.renderPermissions(member)}
+        {syncEnabled && this.renderLabels(member.labels)}
+        <td className="text-right">
+          <DeleteButton onConfirm={() => this.onRemoveMember(member)} disabled={!signedInUserIsTeamAdmin} />
+        </td>
+      </tr>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {};
+}
+
+const mapDispatchToProps = {
+  removeTeamMember,
+  updateTeamMember,
+};
+
+export default connect(
+  mapStateToProps,
+  mapDispatchToProps
+)(TeamMemberRow);

+ 15 - 28
public/app/features/teams/TeamMembers.test.tsx

@@ -1,18 +1,25 @@
 import React from 'react';
 import { shallow } from 'enzyme';
 import { TeamMembers, Props, State } from './TeamMembers';
-import { TeamMember } from '../../types';
-import { getMockTeamMember, getMockTeamMembers } from './__mocks__/teamMocks';
+import { TeamMember, OrgRole } from '../../types';
+import { getMockTeamMembers } from './__mocks__/teamMocks';
+import { User } from 'app/core/services/context_srv';
+
+const signedInUserId = 1;
 
 const setup = (propOverrides?: object) => {
   const props: Props = {
     members: [] as TeamMember[],
     searchMemberQuery: '',
     setSearchMemberQuery: jest.fn(),
-    loadTeamMembers: jest.fn(),
     addTeamMember: jest.fn(),
-    removeTeamMember: jest.fn(),
     syncEnabled: false,
+    editorsCanAdmin: false,
+    signedInUser: {
+      id: signedInUserId,
+      isGrafanaAdmin: false,
+      orgRole: OrgRole.Viewer,
+    } as User,
   };
 
   Object.assign(props, propOverrides);
@@ -28,24 +35,13 @@ const setup = (propOverrides?: object) => {
 
 describe('Render', () => {
   it('should render component', () => {
-    const { wrapper } = setup();
+    const { wrapper } = setup({});
 
     expect(wrapper).toMatchSnapshot();
   });
 
   it('should render team members', () => {
-    const { wrapper } = setup({
-      members: getMockTeamMembers(5),
-    });
-
-    expect(wrapper).toMatchSnapshot();
-  });
-
-  it('should render team members when sync enabled', () => {
-    const { wrapper } = setup({
-      members: getMockTeamMembers(5),
-      syncEnabled: true,
-    });
+    const { wrapper } = setup({ members: getMockTeamMembers(5, 5) });
 
     expect(wrapper).toMatchSnapshot();
   });
@@ -54,7 +50,7 @@ describe('Render', () => {
 describe('Functions', () => {
   describe('on search member query change', () => {
     it('it should call setSearchMemberQuery', () => {
-      const { instance } = setup();
+      const { instance } = setup({});
 
       instance.onSearchQueryChange('member');
 
@@ -62,17 +58,8 @@ describe('Functions', () => {
     });
   });
 
-  describe('on remove member', () => {
-    const { instance } = setup();
-    const mockTeamMember = getMockTeamMember();
-
-    instance.onRemoveMember(mockTeamMember);
-
-    expect(instance.props.removeTeamMember).toHaveBeenCalledWith(1);
-  });
-
   describe('on add user to team', () => {
-    const { wrapper, instance } = setup();
+    const { wrapper, instance } = setup({});
     const state = wrapper.state() as State;
 
     state.newTeamMember = {

+ 33 - 35
public/app/features/teams/TeamMembers.tsx

@@ -2,21 +2,24 @@ import React, { PureComponent } from 'react';
 import { connect } from 'react-redux';
 import SlideDown from 'app/core/components/Animations/SlideDown';
 import { UserPicker } from 'app/core/components/Select/UserPicker';
-import { DeleteButton } from '@grafana/ui';
 import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
 import { TeamMember, User } from 'app/types';
-import { loadTeamMembers, addTeamMember, removeTeamMember, setSearchMemberQuery } from './state/actions';
-import { getSearchMemberQuery, getTeamMembers } from './state/selectors';
+import { addTeamMember, setSearchMemberQuery } from './state/actions';
+import { getSearchMemberQuery, isSignedInUserTeamAdmin } from './state/selectors';
 import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
+import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle';
+import { config } from 'app/core/config';
+import { contextSrv, User as SignedInUser } from 'app/core/services/context_srv';
+import TeamMemberRow from './TeamMemberRow';
 
 export interface Props {
   members: TeamMember[];
   searchMemberQuery: string;
-  loadTeamMembers: typeof loadTeamMembers;
   addTeamMember: typeof addTeamMember;
-  removeTeamMember: typeof removeTeamMember;
   setSearchMemberQuery: typeof setSearchMemberQuery;
   syncEnabled: boolean;
+  editorsCanAdmin?: boolean;
+  signedInUser?: SignedInUser;
 }
 
 export interface State {
@@ -30,18 +33,10 @@ export class TeamMembers extends PureComponent<Props, State> {
     this.state = { isAdding: false, newTeamMember: null };
   }
 
-  componentDidMount() {
-    this.props.loadTeamMembers();
-  }
-
   onSearchQueryChange = (value: string) => {
     this.props.setSearchMemberQuery(value);
   };
 
-  onRemoveMember(member: TeamMember) {
-    this.props.removeTeamMember(member.userId);
-  }
-
   onToggleAdding = () => {
     this.setState({ isAdding: !this.state.isAdding });
   };
@@ -69,25 +64,11 @@ export class TeamMembers extends PureComponent<Props, State> {
     );
   }
 
-  renderMember(member: TeamMember, syncEnabled: boolean) {
-    return (
-      <tr key={member.userId}>
-        <td className="width-4 text-center">
-          <img className="filter-table__avatar" src={member.avatarUrl} />
-        </td>
-        <td>{member.login}</td>
-        <td>{member.email}</td>
-        {syncEnabled && this.renderLabels(member.labels)}
-        <td className="text-right">
-          <DeleteButton onConfirm={() => this.onRemoveMember(member)} />
-        </td>
-      </tr>
-    );
-  }
-
   render() {
     const { isAdding } = this.state;
-    const { searchMemberQuery, members, syncEnabled } = this.props;
+    const { searchMemberQuery, members, syncEnabled, editorsCanAdmin, signedInUser } = this.props;
+    const isTeamAdmin = isSignedInUserTeamAdmin({ members, editorsCanAdmin, signedInUser });
+
     return (
       <div>
         <div className="page-action-bar">
@@ -103,7 +84,11 @@ export class TeamMembers extends PureComponent<Props, State> {
 
           <div className="page-action-bar__spacer" />
 
-          <button className="btn btn-primary pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
+          <button
+            className="btn btn-primary pull-right"
+            onClick={this.onToggleAdding}
+            disabled={isAdding || !isTeamAdmin}
+          >
             Add member
           </button>
         </div>
@@ -132,11 +117,25 @@ export class TeamMembers extends PureComponent<Props, State> {
                 <th />
                 <th>Name</th>
                 <th>Email</th>
+                <WithFeatureToggle featureToggle={editorsCanAdmin}>
+                  <th>Permission</th>
+                </WithFeatureToggle>
                 {syncEnabled && <th />}
                 <th style={{ width: '1%' }} />
               </tr>
             </thead>
-            <tbody>{members && members.map(member => this.renderMember(member, syncEnabled))}</tbody>
+            <tbody>
+              {members &&
+                members.map(member => (
+                  <TeamMemberRow
+                    key={member.userId}
+                    member={member}
+                    syncEnabled={syncEnabled}
+                    editorsCanAdmin={editorsCanAdmin}
+                    signedInUserIsTeamAdmin={isTeamAdmin}
+                  />
+                ))}
+            </tbody>
           </table>
         </div>
       </div>
@@ -146,15 +145,14 @@ export class TeamMembers extends PureComponent<Props, State> {
 
 function mapStateToProps(state) {
   return {
-    members: getTeamMembers(state.team),
     searchMemberQuery: getSearchMemberQuery(state.team),
+    editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
+    signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
   };
 }
 
 const mapDispatchToProps = {
-  loadTeamMembers,
   addTeamMember,
-  removeTeamMember,
   setSearchMemberQuery,
 };
 

+ 52 - 1
public/app/features/teams/TeamPages.test.tsx

@@ -1,8 +1,9 @@
 import React from 'react';
 import { shallow } from 'enzyme';
 import { TeamPages, Props } from './TeamPages';
-import { NavModel, Team } from '../../types';
+import { NavModel, Team, TeamMember, OrgRole } from '../../types';
 import { getMockTeam } from './__mocks__/teamMocks';
+import { User } from 'app/core/services/context_srv';
 
 jest.mock('app/core/config', () => ({
   buildInfo: { isEnterprise: true },
@@ -13,8 +14,16 @@ const setup = (propOverrides?: object) => {
     navModel: {} as NavModel,
     teamId: 1,
     loadTeam: jest.fn(),
+    loadTeamMembers: jest.fn(),
     pageName: 'members',
     team: {} as Team,
+    members: [] as TeamMember[],
+    editorsCanAdmin: false,
+    signedInUser: {
+      id: 1,
+      isGrafanaAdmin: false,
+      orgRole: OrgRole.Viewer,
+    } as User,
   };
 
   Object.assign(props, propOverrides);
@@ -65,4 +74,46 @@ describe('Render', () => {
 
     expect(wrapper).toMatchSnapshot();
   });
+
+  describe('when feature toggle editorsCanAdmin is turned on', () => {
+    it('should render settings page if user is team admin', () => {
+      const { wrapper } = setup({
+        team: getMockTeam(),
+        pageName: 'settings',
+        preferences: {
+          homeDashboardId: 1,
+          theme: 'Default',
+          timezone: 'Default',
+        },
+        editorsCanAdmin: true,
+        signedInUser: {
+          id: 1,
+          isGrafanaAdmin: false,
+          orgRole: OrgRole.Admin,
+        } as User,
+      });
+
+      expect(wrapper).toMatchSnapshot();
+    });
+
+    it('should not render settings page if user is team member', () => {
+      const { wrapper } = setup({
+        team: getMockTeam(),
+        pageName: 'settings',
+        preferences: {
+          homeDashboardId: 1,
+          theme: 'Default',
+          timezone: 'Default',
+        },
+        editorsCanAdmin: true,
+        signedInUser: {
+          id: 1,
+          isGrafanaAdmin: false,
+          orgRole: OrgRole.Viewer,
+        } as User,
+      });
+
+      expect(wrapper).toMatchSnapshot();
+    });
+  });
 });

+ 51 - 12
public/app/features/teams/TeamPages.tsx

@@ -7,19 +7,24 @@ import Page from 'app/core/components/Page/Page';
 import TeamMembers from './TeamMembers';
 import TeamSettings from './TeamSettings';
 import TeamGroupSync from './TeamGroupSync';
-import { NavModel, Team } from 'app/types';
-import { loadTeam } from './state/actions';
-import { getTeam } from './state/selectors';
+import { NavModel, Team, TeamMember } from 'app/types';
+import { loadTeam, loadTeamMembers } from './state/actions';
+import { getTeam, getTeamMembers, isSignedInUserTeamAdmin } from './state/selectors';
 import { getTeamLoadingNav } from './state/navModel';
 import { getNavModel } from 'app/core/selectors/navModel';
 import { getRouteParamsId, getRouteParamsPage } from '../../core/selectors/location';
+import { contextSrv, User } from 'app/core/services/context_srv';
 
 export interface Props {
   team: Team;
   loadTeam: typeof loadTeam;
+  loadTeamMembers: typeof loadTeamMembers;
   teamId: number;
   pageName: string;
   navModel: NavModel;
+  members?: TeamMember[];
+  editorsCanAdmin?: boolean;
+  signedInUser?: User;
 }
 
 interface State {
@@ -51,6 +56,7 @@ export class TeamPages extends PureComponent<Props, State> {
     const { loadTeam, teamId } = this.props;
     this.setState({ isLoading: true });
     const team = await loadTeam(teamId);
+    await this.props.loadTeamMembers();
     this.setState({ isLoading: false });
     return team;
   }
@@ -61,30 +67,56 @@ export class TeamPages extends PureComponent<Props, State> {
     return _.includes(pages, currentPage) ? currentPage : pages[0];
   }
 
-  renderPage() {
+  textsAreEqual = (text1: string, text2: string) => {
+    if (!text1 && !text2) {
+      return true;
+    }
+
+    if (!text1 || !text2) {
+      return false;
+    }
+
+    return text1.toLocaleLowerCase() === text2.toLocaleLowerCase();
+  };
+
+  hideTabsFromNonTeamAdmin = (navModel: NavModel, isSignedInUserTeamAdmin: boolean) => {
+    if (!isSignedInUserTeamAdmin && navModel.main && navModel.main.children) {
+      navModel.main.children
+        .filter(navItem => !this.textsAreEqual(navItem.text, PageTypes.Members))
+        .map(navItem => {
+          navItem.hideFromTabs = true;
+        });
+    }
+
+    return navModel;
+  };
+
+  renderPage(isSignedInUserTeamAdmin: boolean) {
     const { isSyncEnabled } = this.state;
+    const { members } = this.props;
     const currentPage = this.getCurrentPage();
 
     switch (currentPage) {
       case PageTypes.Members:
-        return <TeamMembers syncEnabled={isSyncEnabled} />;
+        return <TeamMembers syncEnabled={isSyncEnabled} members={members} />;
 
       case PageTypes.Settings:
-        return <TeamSettings />;
+        return isSignedInUserTeamAdmin && <TeamSettings />;
       case PageTypes.GroupSync:
-        return isSyncEnabled && <TeamGroupSync />;
+        return isSignedInUserTeamAdmin && isSyncEnabled && <TeamGroupSync />;
     }
 
     return null;
   }
 
   render() {
-    const { team, navModel } = this.props;
+    const { team, navModel, members, editorsCanAdmin, signedInUser } = this.props;
+    const isTeamAdmin = isSignedInUserTeamAdmin({ members, editorsCanAdmin, signedInUser });
 
     return (
-      <Page navModel={navModel}>
+      <Page navModel={this.hideTabsFromNonTeamAdmin(navModel, isTeamAdmin)}>
         <Page.Contents isLoading={this.state.isLoading}>
-          {team && Object.keys(team).length !== 0 && this.renderPage()}
+          {team && Object.keys(team).length !== 0 && this.renderPage(isTeamAdmin)}
         </Page.Contents>
       </Page>
     );
@@ -95,17 +127,24 @@ function mapStateToProps(state) {
   const teamId = getRouteParamsId(state.location);
   const pageName = getRouteParamsPage(state.location) || 'members';
   const teamLoadingNav = getTeamLoadingNav(pageName);
+  const navModel = getNavModel(state.navIndex, `team-${pageName}-${teamId}`, teamLoadingNav);
+  const team = getTeam(state.team, teamId);
+  const members = getTeamMembers(state.team);
 
   return {
-    navModel: getNavModel(state.navIndex, `team-${pageName}-${teamId}`, teamLoadingNav),
+    navModel,
     teamId: teamId,
     pageName: pageName,
-    team: getTeam(state.team, teamId),
+    team,
+    members,
+    editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
+    signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
   };
 }
 
 const mapDispatchToProps = {
   loadTeam,
+  loadTeamMembers,
 };
 
 export default hot(module)(

+ 6 - 2
public/app/features/teams/__mocks__/teamMocks.ts

@@ -1,4 +1,4 @@
-import { Team, TeamGroup, TeamMember } from 'app/types';
+import { Team, TeamGroup, TeamMember, TeamPermissionLevel } from 'app/types';
 
 export const getMultipleMockTeams = (numberOfTeams: number): Team[] => {
   const teams: Team[] = [];
@@ -9,6 +9,7 @@ export const getMultipleMockTeams = (numberOfTeams: number): Team[] => {
       avatarUrl: 'some/url/',
       email: `test-${i}@test.com`,
       memberCount: i,
+      permission: TeamPermissionLevel.Member,
     });
   }
 
@@ -22,10 +23,11 @@ export const getMockTeam = (): Team => {
     avatarUrl: 'some/url/',
     email: 'test@test.com',
     memberCount: 1,
+    permission: TeamPermissionLevel.Member,
   };
 };
 
-export const getMockTeamMembers = (amount: number): TeamMember[] => {
+export const getMockTeamMembers = (amount: number, teamAdminId: number): TeamMember[] => {
   const teamMembers: TeamMember[] = [];
 
   for (let i = 1; i <= amount; i++) {
@@ -36,6 +38,7 @@ export const getMockTeamMembers = (amount: number): TeamMember[] => {
       email: 'test@test.com',
       login: `testUser-${i}`,
       labels: ['label 1', 'label 2'],
+      permission: i === teamAdminId ? TeamPermissionLevel.Admin : TeamPermissionLevel.Member,
     });
   }
 
@@ -50,6 +53,7 @@ export const getMockTeamMember = (): TeamMember => {
     email: 'test@test.com',
     login: 'testUser',
     labels: [],
+    permission: TeamPermissionLevel.Member,
   };
 };
 

+ 257 - 0
public/app/features/teams/__snapshots__/TeamList.test.tsx.snap

@@ -133,6 +133,7 @@ exports[`Render should render teams table 1`] = `
               className="text-right"
             >
               <DeleteButton
+                disabled={false}
                 onConfirm={[Function]}
               />
             </td>
@@ -183,6 +184,7 @@ exports[`Render should render teams table 1`] = `
               className="text-right"
             >
               <DeleteButton
+                disabled={false}
                 onConfirm={[Function]}
               />
             </td>
@@ -233,6 +235,7 @@ exports[`Render should render teams table 1`] = `
               className="text-right"
             >
               <DeleteButton
+                disabled={false}
                 onConfirm={[Function]}
               />
             </td>
@@ -283,6 +286,7 @@ exports[`Render should render teams table 1`] = `
               className="text-right"
             >
               <DeleteButton
+                disabled={false}
                 onConfirm={[Function]}
               />
             </td>
@@ -333,6 +337,259 @@ exports[`Render should render teams table 1`] = `
               className="text-right"
             >
               <DeleteButton
+                disabled={false}
+                onConfirm={[Function]}
+              />
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+  </PageContents>
+</Page>
+`;
+
+exports[`Render when feature toggle editorsCanAdmin is turned on and signedin user is a viewer should disable the new team button 1`] = `
+<Page
+  navModel={
+    Object {
+      "main": Object {
+        "text": "Configuration",
+      },
+      "node": Object {
+        "text": "Team List",
+      },
+    }
+  }
+>
+  <PageContents
+    isLoading={false}
+  >
+    <div
+      className="page-action-bar"
+    >
+      <div
+        className="gf-form gf-form--grow"
+      >
+        <ForwardRef
+          inputClassName="gf-form-input"
+          labelClassName="gf-form--has-input-icon gf-form--grow"
+          onChange={[Function]}
+          placeholder="Search teams"
+          value=""
+        />
+      </div>
+      <div
+        className="page-action-bar__spacer"
+      />
+      <a
+        className="btn btn-primary disabled"
+        href="#"
+      >
+        New team
+      </a>
+    </div>
+    <div
+      className="admin-list-table"
+    >
+      <table
+        className="filter-table filter-table--hover form-inline"
+      >
+        <thead>
+          <tr>
+            <th />
+            <th>
+              Name
+            </th>
+            <th>
+              Email
+            </th>
+            <th>
+              Members
+            </th>
+            <th
+              style={
+                Object {
+                  "width": "1%",
+                }
+              }
+            />
+          </tr>
+        </thead>
+        <tbody>
+          <tr
+            key="1"
+          >
+            <td
+              className="width-4 text-center link-td"
+            >
+              <a
+                href="org/teams/edit/1"
+              >
+                <img
+                  className="filter-table__avatar"
+                  src="some/url/"
+                />
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/1"
+              >
+                test-1
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/1"
+              >
+                test-1@test.com
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/1"
+              >
+                1
+              </a>
+            </td>
+            <td
+              className="text-right"
+            >
+              <DeleteButton
+                disabled={true}
+                onConfirm={[Function]}
+              />
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+  </PageContents>
+</Page>
+`;
+
+exports[`Render when feature toggle editorsCanAdmin is turned on and signedin user is not viewer should enable the new team button 1`] = `
+<Page
+  navModel={
+    Object {
+      "main": Object {
+        "text": "Configuration",
+      },
+      "node": Object {
+        "text": "Team List",
+      },
+    }
+  }
+>
+  <PageContents
+    isLoading={false}
+  >
+    <div
+      className="page-action-bar"
+    >
+      <div
+        className="gf-form gf-form--grow"
+      >
+        <ForwardRef
+          inputClassName="gf-form-input"
+          labelClassName="gf-form--has-input-icon gf-form--grow"
+          onChange={[Function]}
+          placeholder="Search teams"
+          value=""
+        />
+      </div>
+      <div
+        className="page-action-bar__spacer"
+      />
+      <a
+        className="btn btn-primary"
+        href="org/teams/new"
+      >
+        New team
+      </a>
+    </div>
+    <div
+      className="admin-list-table"
+    >
+      <table
+        className="filter-table filter-table--hover form-inline"
+      >
+        <thead>
+          <tr>
+            <th />
+            <th>
+              Name
+            </th>
+            <th>
+              Email
+            </th>
+            <th>
+              Members
+            </th>
+            <th
+              style={
+                Object {
+                  "width": "1%",
+                }
+              }
+            />
+          </tr>
+        </thead>
+        <tbody>
+          <tr
+            key="1"
+          >
+            <td
+              className="width-4 text-center link-td"
+            >
+              <a
+                href="org/teams/edit/1"
+              >
+                <img
+                  className="filter-table__avatar"
+                  src="some/url/"
+                />
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/1"
+              >
+                test-1
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/1"
+              >
+                test-1@test.com
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/1"
+              >
+                1
+              </a>
+            </td>
+            <td
+              className="text-right"
+            >
+              <DeleteButton
+                disabled={true}
                 onConfirm={[Function]}
               />
             </td>

+ 250 - 0
public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap

@@ -0,0 +1,250 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render team members when sync enabled 1`] = `
+<tr
+  key="1"
+>
+  <td
+    className="width-4 text-center"
+  >
+    <img
+      className="filter-table__avatar"
+      src="some/url/"
+    />
+  </td>
+  <td>
+    testUser
+  </td>
+  <td>
+    test@test.com
+  </td>
+  <Component
+    featureToggle={false}
+  >
+    <td
+      className="width-5 team-permissions"
+    >
+      <div
+        className="gf-form"
+      >
+        <span>
+          Member
+        </span>
+      </div>
+    </td>
+  </Component>
+  <td>
+    <TagBadge
+      count={0}
+      key="LDAP"
+      label="LDAP"
+      onClick={[Function]}
+      removeIcon={false}
+    />
+  </td>
+  <td
+    className="text-right"
+  >
+    <DeleteButton
+      disabled={true}
+      onConfirm={[Function]}
+    />
+  </td>
+</tr>
+`;
+
+exports[`Render when feature toggle editorsCanAdmin is turned off should not render permissions 1`] = `
+<tr
+  key="1"
+>
+  <td
+    className="width-4 text-center"
+  >
+    <img
+      className="filter-table__avatar"
+      src="some/url/"
+    />
+  </td>
+  <td>
+    testUser
+  </td>
+  <td>
+    test@test.com
+  </td>
+  <Component
+    featureToggle={false}
+  >
+    <td
+      className="width-5 team-permissions"
+    >
+      <div
+        className="gf-form"
+      >
+        <Select
+          autoFocus={false}
+          backspaceRemovesValue={true}
+          className="gf-form-select-box__control--menu-right"
+          isClearable={false}
+          isDisabled={false}
+          isLoading={false}
+          isMulti={false}
+          isSearchable={false}
+          maxMenuHeight={300}
+          onChange={[Function]}
+          openMenuOnFocus={false}
+          options={
+            Array [
+              Object {
+                "description": "Is team member",
+                "label": "Member",
+                "value": 0,
+              },
+              Object {
+                "description": "Can add/remove permissions, members and delete team.",
+                "label": "Admin",
+                "value": 4,
+              },
+            ]
+          }
+          value={
+            Object {
+              "description": "Is team member",
+              "label": "Member",
+              "value": 0,
+            }
+          }
+          width={null}
+        />
+      </div>
+    </td>
+  </Component>
+  <td
+    className="text-right"
+  >
+    <DeleteButton
+      disabled={false}
+      onConfirm={[Function]}
+    />
+  </td>
+</tr>
+`;
+
+exports[`Render when feature toggle editorsCanAdmin is turned on should render permissions select if user is team admin 1`] = `
+<tr
+  key="1"
+>
+  <td
+    className="width-4 text-center"
+  >
+    <img
+      className="filter-table__avatar"
+      src="some/url/"
+    />
+  </td>
+  <td>
+    testUser
+  </td>
+  <td>
+    test@test.com
+  </td>
+  <Component
+    featureToggle={true}
+  >
+    <td
+      className="width-5 team-permissions"
+    >
+      <div
+        className="gf-form"
+      >
+        <Select
+          autoFocus={false}
+          backspaceRemovesValue={true}
+          className="gf-form-select-box__control--menu-right"
+          isClearable={false}
+          isDisabled={false}
+          isLoading={false}
+          isMulti={false}
+          isSearchable={false}
+          maxMenuHeight={300}
+          onChange={[Function]}
+          openMenuOnFocus={false}
+          options={
+            Array [
+              Object {
+                "description": "Is team member",
+                "label": "Member",
+                "value": 0,
+              },
+              Object {
+                "description": "Can add/remove permissions, members and delete team.",
+                "label": "Admin",
+                "value": 4,
+              },
+            ]
+          }
+          value={
+            Object {
+              "description": "Is team member",
+              "label": "Member",
+              "value": 0,
+            }
+          }
+          width={null}
+        />
+      </div>
+    </td>
+  </Component>
+  <td
+    className="text-right"
+  >
+    <DeleteButton
+      disabled={false}
+      onConfirm={[Function]}
+    />
+  </td>
+</tr>
+`;
+
+exports[`Render when feature toggle editorsCanAdmin is turned on should render span and disable buttons if user is team member 1`] = `
+<tr
+  key="1"
+>
+  <td
+    className="width-4 text-center"
+  >
+    <img
+      className="filter-table__avatar"
+      src="some/url/"
+    />
+  </td>
+  <td>
+    testUser
+  </td>
+  <td>
+    test@test.com
+  </td>
+  <Component
+    featureToggle={true}
+  >
+    <td
+      className="width-5 team-permissions"
+    >
+      <div
+        className="gf-form"
+      >
+        <span>
+          Member
+        </span>
+      </div>
+    </td>
+  </Component>
+  <td
+    className="text-right"
+  >
+    <DeleteButton
+      disabled={true}
+      onConfirm={[Function]}
+    />
+  </td>
+</tr>
+`;

+ 108 - 410
public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap

@@ -69,6 +69,13 @@ exports[`Render should render component 1`] = `
           <th>
             Email
           </th>
+          <Component
+            featureToggle={false}
+          >
+            <th>
+              Permission
+            </th>
+          </Component>
           <th
             style={
               Object {
@@ -153,217 +160,13 @@ exports[`Render should render team members 1`] = `
           <th>
             Email
           </th>
-          <th
-            style={
-              Object {
-                "width": "1%",
-              }
-            }
-          />
-        </tr>
-      </thead>
-      <tbody>
-        <tr
-          key="1"
-        >
-          <td
-            className="width-4 text-center"
-          >
-            <img
-              className="filter-table__avatar"
-              src="some/url/"
-            />
-          </td>
-          <td>
-            testUser-1
-          </td>
-          <td>
-            test@test.com
-          </td>
-          <td
-            className="text-right"
-          >
-            <DeleteButton
-              onConfirm={[Function]}
-            />
-          </td>
-        </tr>
-        <tr
-          key="2"
-        >
-          <td
-            className="width-4 text-center"
-          >
-            <img
-              className="filter-table__avatar"
-              src="some/url/"
-            />
-          </td>
-          <td>
-            testUser-2
-          </td>
-          <td>
-            test@test.com
-          </td>
-          <td
-            className="text-right"
+          <Component
+            featureToggle={false}
           >
-            <DeleteButton
-              onConfirm={[Function]}
-            />
-          </td>
-        </tr>
-        <tr
-          key="3"
-        >
-          <td
-            className="width-4 text-center"
-          >
-            <img
-              className="filter-table__avatar"
-              src="some/url/"
-            />
-          </td>
-          <td>
-            testUser-3
-          </td>
-          <td>
-            test@test.com
-          </td>
-          <td
-            className="text-right"
-          >
-            <DeleteButton
-              onConfirm={[Function]}
-            />
-          </td>
-        </tr>
-        <tr
-          key="4"
-        >
-          <td
-            className="width-4 text-center"
-          >
-            <img
-              className="filter-table__avatar"
-              src="some/url/"
-            />
-          </td>
-          <td>
-            testUser-4
-          </td>
-          <td>
-            test@test.com
-          </td>
-          <td
-            className="text-right"
-          >
-            <DeleteButton
-              onConfirm={[Function]}
-            />
-          </td>
-        </tr>
-        <tr
-          key="5"
-        >
-          <td
-            className="width-4 text-center"
-          >
-            <img
-              className="filter-table__avatar"
-              src="some/url/"
-            />
-          </td>
-          <td>
-            testUser-5
-          </td>
-          <td>
-            test@test.com
-          </td>
-          <td
-            className="text-right"
-          >
-            <DeleteButton
-              onConfirm={[Function]}
-            />
-          </td>
-        </tr>
-      </tbody>
-    </table>
-  </div>
-</div>
-`;
-
-exports[`Render should render team members when sync enabled 1`] = `
-<div>
-  <div
-    className="page-action-bar"
-  >
-    <div
-      className="gf-form gf-form--grow"
-    >
-      <ForwardRef
-        inputClassName="gf-form-input"
-        labelClassName="gf-form--has-input-icon gf-form--grow"
-        onChange={[Function]}
-        placeholder="Search members"
-        value=""
-      />
-    </div>
-    <div
-      className="page-action-bar__spacer"
-    />
-    <button
-      className="btn btn-primary pull-right"
-      disabled={false}
-      onClick={[Function]}
-    >
-      Add member
-    </button>
-  </div>
-  <Component
-    in={false}
-  >
-    <div
-      className="cta-form"
-    >
-      <button
-        className="cta-form__close btn btn-transparent"
-        onClick={[Function]}
-      >
-        <i
-          className="fa fa-close"
-        />
-      </button>
-      <h5>
-        Add team member
-      </h5>
-      <div
-        className="gf-form-inline"
-      >
-        <UserPicker
-          className="min-width-30"
-          onSelected={[Function]}
-        />
-      </div>
-    </div>
-  </Component>
-  <div
-    className="admin-list-table"
-  >
-    <table
-      className="filter-table filter-table--hover form-inline"
-    >
-      <thead>
-        <tr>
-          <th />
-          <th>
-            Name
-          </th>
-          <th>
-            Email
-          </th>
-          <th />
+            <th>
+              Permission
+            </th>
+          </Component>
           <th
             style={
               Object {
@@ -374,211 +177,106 @@ exports[`Render should render team members when sync enabled 1`] = `
         </tr>
       </thead>
       <tbody>
-        <tr
+        <Connect(TeamMemberRow)
+          editorsCanAdmin={false}
           key="1"
-        >
-          <td
-            className="width-4 text-center"
-          >
-            <img
-              className="filter-table__avatar"
-              src="some/url/"
-            />
-          </td>
-          <td>
-            testUser-1
-          </td>
-          <td>
-            test@test.com
-          </td>
-          <td>
-            <TagBadge
-              count={0}
-              key="label 1"
-              label="label 1"
-              onClick={[Function]}
-              removeIcon={false}
-            />
-            <TagBadge
-              count={0}
-              key="label 2"
-              label="label 2"
-              onClick={[Function]}
-              removeIcon={false}
-            />
-          </td>
-          <td
-            className="text-right"
-          >
-            <DeleteButton
-              onConfirm={[Function]}
-            />
-          </td>
-        </tr>
-        <tr
+          member={
+            Object {
+              "avatarUrl": "some/url/",
+              "email": "test@test.com",
+              "labels": Array [
+                "label 1",
+                "label 2",
+              ],
+              "login": "testUser-1",
+              "permission": 0,
+              "teamId": 1,
+              "userId": 1,
+            }
+          }
+          signedInUserIsTeamAdmin={true}
+          syncEnabled={false}
+        />
+        <Connect(TeamMemberRow)
+          editorsCanAdmin={false}
           key="2"
-        >
-          <td
-            className="width-4 text-center"
-          >
-            <img
-              className="filter-table__avatar"
-              src="some/url/"
-            />
-          </td>
-          <td>
-            testUser-2
-          </td>
-          <td>
-            test@test.com
-          </td>
-          <td>
-            <TagBadge
-              count={0}
-              key="label 1"
-              label="label 1"
-              onClick={[Function]}
-              removeIcon={false}
-            />
-            <TagBadge
-              count={0}
-              key="label 2"
-              label="label 2"
-              onClick={[Function]}
-              removeIcon={false}
-            />
-          </td>
-          <td
-            className="text-right"
-          >
-            <DeleteButton
-              onConfirm={[Function]}
-            />
-          </td>
-        </tr>
-        <tr
+          member={
+            Object {
+              "avatarUrl": "some/url/",
+              "email": "test@test.com",
+              "labels": Array [
+                "label 1",
+                "label 2",
+              ],
+              "login": "testUser-2",
+              "permission": 0,
+              "teamId": 1,
+              "userId": 2,
+            }
+          }
+          signedInUserIsTeamAdmin={true}
+          syncEnabled={false}
+        />
+        <Connect(TeamMemberRow)
+          editorsCanAdmin={false}
           key="3"
-        >
-          <td
-            className="width-4 text-center"
-          >
-            <img
-              className="filter-table__avatar"
-              src="some/url/"
-            />
-          </td>
-          <td>
-            testUser-3
-          </td>
-          <td>
-            test@test.com
-          </td>
-          <td>
-            <TagBadge
-              count={0}
-              key="label 1"
-              label="label 1"
-              onClick={[Function]}
-              removeIcon={false}
-            />
-            <TagBadge
-              count={0}
-              key="label 2"
-              label="label 2"
-              onClick={[Function]}
-              removeIcon={false}
-            />
-          </td>
-          <td
-            className="text-right"
-          >
-            <DeleteButton
-              onConfirm={[Function]}
-            />
-          </td>
-        </tr>
-        <tr
+          member={
+            Object {
+              "avatarUrl": "some/url/",
+              "email": "test@test.com",
+              "labels": Array [
+                "label 1",
+                "label 2",
+              ],
+              "login": "testUser-3",
+              "permission": 0,
+              "teamId": 1,
+              "userId": 3,
+            }
+          }
+          signedInUserIsTeamAdmin={true}
+          syncEnabled={false}
+        />
+        <Connect(TeamMemberRow)
+          editorsCanAdmin={false}
           key="4"
-        >
-          <td
-            className="width-4 text-center"
-          >
-            <img
-              className="filter-table__avatar"
-              src="some/url/"
-            />
-          </td>
-          <td>
-            testUser-4
-          </td>
-          <td>
-            test@test.com
-          </td>
-          <td>
-            <TagBadge
-              count={0}
-              key="label 1"
-              label="label 1"
-              onClick={[Function]}
-              removeIcon={false}
-            />
-            <TagBadge
-              count={0}
-              key="label 2"
-              label="label 2"
-              onClick={[Function]}
-              removeIcon={false}
-            />
-          </td>
-          <td
-            className="text-right"
-          >
-            <DeleteButton
-              onConfirm={[Function]}
-            />
-          </td>
-        </tr>
-        <tr
+          member={
+            Object {
+              "avatarUrl": "some/url/",
+              "email": "test@test.com",
+              "labels": Array [
+                "label 1",
+                "label 2",
+              ],
+              "login": "testUser-4",
+              "permission": 0,
+              "teamId": 1,
+              "userId": 4,
+            }
+          }
+          signedInUserIsTeamAdmin={true}
+          syncEnabled={false}
+        />
+        <Connect(TeamMemberRow)
+          editorsCanAdmin={false}
           key="5"
-        >
-          <td
-            className="width-4 text-center"
-          >
-            <img
-              className="filter-table__avatar"
-              src="some/url/"
-            />
-          </td>
-          <td>
-            testUser-5
-          </td>
-          <td>
-            test@test.com
-          </td>
-          <td>
-            <TagBadge
-              count={0}
-              key="label 1"
-              label="label 1"
-              onClick={[Function]}
-              removeIcon={false}
-            />
-            <TagBadge
-              count={0}
-              key="label 2"
-              label="label 2"
-              onClick={[Function]}
-              removeIcon={false}
-            />
-          </td>
-          <td
-            className="text-right"
-          >
-            <DeleteButton
-              onConfirm={[Function]}
-            />
-          </td>
-        </tr>
+          member={
+            Object {
+              "avatarUrl": "some/url/",
+              "email": "test@test.com",
+              "labels": Array [
+                "label 1",
+                "label 2",
+              ],
+              "login": "testUser-5",
+              "permission": 4,
+              "teamId": 1,
+              "userId": 5,
+            }
+          }
+          signedInUserIsTeamAdmin={true}
+          syncEnabled={false}
+        />
       </tbody>
     </table>
   </div>

+ 23 - 0
public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap

@@ -30,6 +30,7 @@ exports[`Render should render member page if team not empty 1`] = `
     isLoading={true}
   >
     <Connect(TeamMembers)
+      members={Array []}
       syncEnabled={true}
     />
   </PageContents>
@@ -47,3 +48,25 @@ exports[`Render should render settings and preferences page 1`] = `
   </PageContents>
 </Page>
 `;
+
+exports[`Render when feature toggle editorsCanAdmin is turned on should not render settings page if user is team member 1`] = `
+<Page
+  navModel={Object {}}
+>
+  <PageContents
+    isLoading={true}
+  />
+</Page>
+`;
+
+exports[`Render when feature toggle editorsCanAdmin is turned on should render settings page if user is team admin 1`] = `
+<Page
+  navModel={Object {}}
+>
+  <PageContents
+    isLoading={true}
+  >
+    <Connect(TeamSettings) />
+  </PageContents>
+</Page>
+`;

+ 9 - 0
public/app/features/teams/state/actions.ts

@@ -160,3 +160,12 @@ export function deleteTeam(id: number): ThunkResult<void> {
     dispatch(loadTeams());
   };
 }
+
+export function updateTeamMember(member: TeamMember): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv().put(`/api/teams/${member.teamId}/members/${member.userId}`, {
+      permission: member.permission,
+    });
+    dispatch(loadTeamMembers());
+  };
+}

+ 2 - 1
public/app/features/teams/state/navModel.ts

@@ -1,4 +1,4 @@
-import { Team, NavModelItem, NavModel } from 'app/types';
+import { Team, NavModelItem, NavModel, TeamPermissionLevel } from 'app/types';
 import config from 'app/core/config';
 
 export function buildNavModel(team: Team): NavModelItem {
@@ -47,6 +47,7 @@ export function getTeamLoadingNav(pageName: string): NavModel {
     name: 'Loading',
     email: 'loading',
     memberCount: 0,
+    permission: TeamPermissionLevel.Member,
   });
 
   let node: NavModelItem;

+ 95 - 3
public/app/features/teams/state/selectors.test.ts

@@ -1,6 +1,7 @@
-import { getTeam, getTeamMembers, getTeams } from './selectors';
+import { getTeam, getTeamMembers, getTeams, isSignedInUserTeamAdmin, Config } from './selectors';
 import { getMockTeam, getMockTeamMembers, getMultipleMockTeams } from '../__mocks__/teamMocks';
-import { Team, TeamGroup, TeamsState, TeamState } from '../../../types';
+import { Team, TeamGroup, TeamsState, TeamState, OrgRole } from '../../../types';
+import { User } from 'app/core/services/context_srv';
 
 describe('Teams selectors', () => {
   describe('Get teams', () => {
@@ -40,7 +41,7 @@ describe('Team selectors', () => {
   });
 
   describe('Get members', () => {
-    const mockTeamMembers = getMockTeamMembers(5);
+    const mockTeamMembers = getMockTeamMembers(5, 5);
 
     it('should return team members', () => {
       const mockState: TeamState = {
@@ -55,3 +56,94 @@ describe('Team selectors', () => {
     });
   });
 });
+
+const signedInUserId = 1;
+
+const setup = (configOverrides?: Partial<Config>) => {
+  const defaultConfig: Config = {
+    editorsCanAdmin: false,
+    members: getMockTeamMembers(5, 5),
+    signedInUser: {
+      id: signedInUserId,
+      isGrafanaAdmin: false,
+      orgRole: OrgRole.Viewer,
+    } as User,
+  };
+
+  return { ...defaultConfig, ...configOverrides };
+};
+
+describe('isSignedInUserTeamAdmin', () => {
+  describe('when feature toggle editorsCanAdmin is turned off', () => {
+    it('should return true', () => {
+      const config = setup();
+
+      const result = isSignedInUserTeamAdmin(config);
+
+      expect(result).toBe(true);
+    });
+  });
+
+  describe('when feature toggle editorsCanAdmin is turned on', () => {
+    it('should return true if signed in user is grafanaAdmin', () => {
+      const config = setup({
+        editorsCanAdmin: true,
+        signedInUser: {
+          id: signedInUserId,
+          isGrafanaAdmin: true,
+          orgRole: OrgRole.Viewer,
+        } as User,
+      });
+
+      const result = isSignedInUserTeamAdmin(config);
+
+      expect(result).toBe(true);
+    });
+
+    it('should return true if signed in user is org admin', () => {
+      const config = setup({
+        editorsCanAdmin: true,
+        signedInUser: {
+          id: signedInUserId,
+          isGrafanaAdmin: false,
+          orgRole: OrgRole.Admin,
+        } as User,
+      });
+
+      const result = isSignedInUserTeamAdmin(config);
+
+      expect(result).toBe(true);
+    });
+
+    it('should return true if signed in user is team admin', () => {
+      const config = setup({
+        members: getMockTeamMembers(5, signedInUserId),
+        editorsCanAdmin: true,
+        signedInUser: {
+          id: signedInUserId,
+          isGrafanaAdmin: false,
+          orgRole: OrgRole.Viewer,
+        } as User,
+      });
+
+      const result = isSignedInUserTeamAdmin(config);
+
+      expect(result).toBe(true);
+    });
+
+    it('should return false if signed in user is not grafanaAdmin, org admin or team admin', () => {
+      const config = setup({
+        editorsCanAdmin: true,
+        signedInUser: {
+          id: signedInUserId,
+          isGrafanaAdmin: false,
+          orgRole: OrgRole.Viewer,
+        } as User,
+      });
+
+      const result = isSignedInUserTeamAdmin(config);
+
+      expect(result).toBe(false);
+    });
+  });
+});

+ 31 - 1
public/app/features/teams/state/selectors.ts

@@ -1,4 +1,5 @@
-import { Team, TeamsState, TeamState } from 'app/types';
+import { Team, TeamsState, TeamState, TeamMember, OrgRole, TeamPermissionLevel } from 'app/types';
+import { User } from 'app/core/services/context_srv';
 
 export const getSearchQuery = (state: TeamsState) => state.searchQuery;
 export const getSearchMemberQuery = (state: TeamState) => state.searchMemberQuery;
@@ -28,3 +29,32 @@ export const getTeamMembers = (state: TeamState) => {
     return regex.test(member.login) || regex.test(member.email);
   });
 };
+
+export interface Config {
+  members: TeamMember[];
+  editorsCanAdmin: boolean;
+  signedInUser: User;
+}
+
+export const isSignedInUserTeamAdmin = (config: Config): boolean => {
+  const { members, signedInUser, editorsCanAdmin } = config;
+  const userInMembers = members.find(m => m.userId === signedInUser.id);
+  const permission = userInMembers ? userInMembers.permission : TeamPermissionLevel.Member;
+
+  return isPermissionTeamAdmin({ permission, signedInUser, editorsCanAdmin });
+};
+
+export interface PermissionConfig {
+  permission: TeamPermissionLevel;
+  editorsCanAdmin: boolean;
+  signedInUser: User;
+}
+
+export const isPermissionTeamAdmin = (config: PermissionConfig): boolean => {
+  const { permission, signedInUser, editorsCanAdmin } = config;
+  const isAdmin = signedInUser.isGrafanaAdmin || signedInUser.orgRole === OrgRole.Admin;
+  const userIsTeamAdmin = permission === TeamPermissionLevel.Admin;
+  const isSignedInUserTeamAdmin = isAdmin || userIsTeamAdmin;
+
+  return isSignedInUserTeamAdmin || !editorsCanAdmin;
+};

+ 4 - 3
public/app/plugins/panel/bargauge/BarGaugePanel.tsx

@@ -22,6 +22,7 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
         orientation={options.orientation}
         thresholds={options.thresholds}
         theme={config.theme}
+        displayMode={options.displayMode}
       />
     );
   };
@@ -31,8 +32,7 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
   };
 
   render() {
-    const { height, width, options, data } = this.props;
-    const { orientation } = options;
+    const { height, width, options, data, renderCounter } = this.props;
     return (
       <ProcessedValuesRepeater
         getProcessedValues={this.getProcessedValues}
@@ -40,7 +40,8 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
         width={width}
         height={height}
         source={data}
-        orientation={orientation}
+        renderCounter={renderCounter}
+        orientation={options.orientation}
       />
     );
   }

+ 12 - 1
public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx

@@ -6,7 +6,7 @@ import { ThresholdsEditor, ValueMappingsEditor, PanelOptionsGrid, PanelOptionsGr
 
 // Types
 import { FormLabel, PanelEditorProps, Threshold, Select, ValueMapping } from '@grafana/ui';
-import { BarGaugeOptions, orientationOptions } from './types';
+import { BarGaugeOptions, orientationOptions, displayModes } from './types';
 import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
 import { SingleStatValueOptions } from '../singlestat2/types';
 
@@ -32,6 +32,7 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge
   onMinValueChange = ({ target }) => this.props.onOptionsChange({ ...this.props.options, minValue: target.value });
   onMaxValueChange = ({ target }) => this.props.onOptionsChange({ ...this.props.options, maxValue: target.value });
   onOrientationChange = ({ value }) => this.props.onOptionsChange({ ...this.props.options, orientation: value });
+  onDisplayModeChange = ({ value }) => this.props.onOptionsChange({ ...this.props.options, displayMode: value });
 
   render() {
     const { options } = this.props;
@@ -53,6 +54,16 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge
                 value={orientationOptions.find(item => item.value === options.orientation)}
               />
             </div>
+            <div className="form-field">
+              <FormLabel width={8}>Display Mode</FormLabel>
+              <Select
+                width={12}
+                options={displayModes}
+                defaultValue={displayModes[0]}
+                onChange={this.onDisplayModeChange}
+                value={displayModes.find(item => item.value === options.displayMode)}
+              />
+            </div>
           </PanelOptionsGroup>
           <ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} />
         </PanelOptionsGrid>

+ 13 - 6
public/app/plugins/panel/bargauge/types.ts

@@ -1,20 +1,27 @@
 import { VizOrientation, SelectOptionItem } from '@grafana/ui';
-
 import { SingleStatBaseOptions } from '../singlestat2/types';
 
-export const orientationOptions: SelectOptionItem[] = [
-  { value: VizOrientation.Horizontal, label: 'Horizontal' },
-  { value: VizOrientation.Vertical, label: 'Vertical' },
-];
-
 export interface BarGaugeOptions extends SingleStatBaseOptions {
   minValue: number;
   maxValue: number;
+  displayMode: 'basic' | 'lcd' | 'gradient';
 }
 
+export const displayModes: SelectOptionItem[] = [
+  { value: 'gradient', label: 'Gradient' },
+  { value: 'lcd', label: 'Retro LCD' },
+  { value: 'basic', label: 'Basic' },
+];
+
+export const orientationOptions: SelectOptionItem[] = [
+  { value: VizOrientation.Horizontal, label: 'Horizontal' },
+  { value: VizOrientation.Vertical, label: 'Vertical' },
+];
+
 export const defaults: BarGaugeOptions = {
   minValue: 0,
   maxValue: 100,
+  displayMode: 'lcd',
   orientation: VizOrientation.Horizontal,
   valueOptions: {
     unit: 'none',

+ 3 - 3
public/app/plugins/panel/gauge/GaugePanel.tsx

@@ -37,8 +37,7 @@ export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
   };
 
   render() {
-    const { height, width, options, data } = this.props;
-    const { orientation } = options;
+    const { height, width, options, data, renderCounter } = this.props;
     return (
       <ProcessedValuesRepeater
         getProcessedValues={this.getProcessedValues}
@@ -46,7 +45,8 @@ export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
         width={width}
         height={height}
         source={data}
-        orientation={orientation}
+        renderCounter={renderCounter}
+        orientation={options.orientation}
       />
     );
   }

+ 3 - 3
public/app/plugins/panel/singlestat2/ProcessedValuesRepeater.tsx

@@ -7,7 +7,7 @@ export interface Props<T> {
   height: number;
   orientation: VizOrientation;
   source: any; // If this changes, the values will be processed
-  processFlag?: boolean; // change to force processing
+  renderCounter: number; // change to force processing
 
   getProcessedValues: () => T[];
   renderValue: (value: T, width: number, height: number) => JSX.Element;
@@ -30,8 +30,8 @@ export class ProcessedValuesRepeater<T> extends PureComponent<Props<T>, State<T>
   }
 
   componentDidUpdate(prevProps: Props<T>) {
-    const { processFlag, source } = this.props;
-    if (processFlag !== prevProps.processFlag || source !== prevProps.source) {
+    const { renderCounter, source } = this.props;
+    if (renderCounter !== prevProps.renderCounter || source !== prevProps.source) {
       this.setState({ values: this.props.getProcessedValues() });
     }
   }

+ 3 - 3
public/app/plugins/panel/singlestat2/SingleStatPanel.tsx

@@ -56,8 +56,7 @@ export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>
   };
 
   render() {
-    const { height, width, options, data } = this.props;
-    const { orientation } = options;
+    const { height, width, options, data, renderCounter } = this.props;
     return (
       <ProcessedValuesRepeater
         getProcessedValues={this.getProcessedValues}
@@ -65,7 +64,8 @@ export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>
         width={width}
         height={height}
         source={data}
-        orientation={orientation}
+        renderCounter={renderCounter}
+        orientation={options.orientation}
       />
     );
   }

+ 2 - 2
public/app/routes/routes.ts

@@ -195,7 +195,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
     .when('/org/teams', {
       template: '<react-container />',
       resolve: {
-        roles: () => ['Editor', 'Admin'],
+        roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']),
         component: () => TeamList,
       },
     })
@@ -207,7 +207,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
     .when('/org/teams/edit/:id/:page?', {
       template: '<react-container />',
       resolve: {
-        roles: () => ['Admin'],
+        roles: () => (config.editorsCanAdmin ? [] : ['Admin']),
         component: () => TeamPages,
       },
     })

+ 20 - 0
public/app/types/acl.ts

@@ -98,3 +98,23 @@ export const dashboardPermissionLevels: DashboardPermissionInfo[] = [
     description: 'Can add/remove permissions and can add, edit and delete dashboards.',
   },
 ];
+
+export enum TeamPermissionLevel {
+  Member = 0,
+  Admin = 4,
+}
+
+export interface TeamPermissionInfo {
+  value: TeamPermissionLevel;
+  label: string;
+  description: string;
+}
+
+export const teamsPermissionLevels: TeamPermissionInfo[] = [
+  { value: TeamPermissionLevel.Member, label: 'Member', description: 'Is team member' },
+  {
+    value: TeamPermissionLevel.Admin,
+    label: 'Admin',
+    description: 'Can add/remove permissions, members and delete team.',
+  },
+];

+ 0 - 1
public/app/types/index.ts

@@ -12,6 +12,5 @@ export * from './plugins';
 export * from './organization';
 export * from './appNotifications';
 export * from './search';
-export * from './form';
 export * from './explore';
 export * from './store';

+ 4 - 0
public/app/types/teams.ts

@@ -1,9 +1,12 @@
+import { TeamPermissionLevel } from './acl';
+
 export interface Team {
   id: number;
   name: string;
   avatarUrl: string;
   email: string;
   memberCount: number;
+  permission: TeamPermissionLevel;
 }
 
 export interface TeamMember {
@@ -13,6 +16,7 @@ export interface TeamMember {
   email: string;
   login: string;
   labels: string[];
+  permission: number;
 }
 
 export interface TeamGroup {

+ 6 - 0
public/sass/pages/_admin.scss

@@ -19,3 +19,9 @@ td.admin-settings-key {
     margin-bottom: 5px;
   }
 }
+
+.admin-list-table {
+  .team-permissions {
+    padding-right: 120px;
+  }
+}

+ 2 - 1
public/test/specs/helpers.ts

@@ -3,6 +3,7 @@ import config from 'app/core/config';
 import * as dateMath from 'app/core/utils/datemath';
 import { angularMocks, sinon } from '../lib/common';
 import { PanelModel } from 'app/features/dashboard/state/PanelModel';
+import { PanelPlugin } from 'app/types';
 
 export function ControllerTestContext(this: any) {
   const self = this;
@@ -62,7 +63,7 @@ export function ControllerTestContext(this: any) {
         $rootScope.colors.push('#' + i);
       }
 
-      config.panels['test'] = { info: {} };
+      config.panels['test'] = { info: {} } as PanelPlugin;
       self.ctrl = $controller(
         Ctrl,
         { $scope: self.scope },

+ 114 - 0
scripts/build/ci-build/Dockerfile

@@ -0,0 +1,114 @@
+FROM ubuntu:14.04 as toolchain
+
+ENV OSX_SDK_URL=https://s3.dockerproject.org/darwin/v2/ \
+    OSX_SDK=MacOSX10.10.sdk \
+    OSX_MIN=10.10 \
+    CTNG=1.23.0
+
+# FIRST PART
+# build osx64 toolchain (stripped of man documentation)
+# the toolchain produced is not self contained, it needs clang at runtime
+#
+# SECOND PART
+# build gcc (no g++) centos6-x64 toolchain
+# doc: https://crosstool-ng.github.io/docs/
+# apt-get should be all dep to build toolchain
+# sed and 1st echo are for convenience to get the toolchain in /tmp/x86_64-centos6-linux-gnu
+# other echo are to enable build by root (crosstool-NG refuse to do that by default)
+# the last 2 rm are just to save some time and space writing docker layers
+#
+# THIRD PART
+# build fpm and creates a set of deb from gem
+# ruby2.0 depends on ruby1.9.3 which is install as default ruby
+# rm/ln are here to change that
+# created deb depends on rubygem-json but json gem is not build
+# so do by hand
+
+
+# might wanna make sure osx cross and the other tarball as well as the packages ends up somewhere other than tmp
+# might also wanna put them as their own layer to not have to unpack them every time?
+
+RUN apt-get update   && \
+    apt-get install -y  \
+        clang-3.8 patch libxml2-dev \
+        ca-certificates \
+        curl            \
+        git             \
+        make            \
+        xz-utils     && \
+    git clone https://github.com/tpoechtrager/osxcross.git  /tmp/osxcross  && \
+    curl -L ${OSX_SDK_URL}/${OSX_SDK}.tar.xz -o /tmp/osxcross/tarballs/${OSX_SDK}.tar.xz && \
+    ln -s /usr/bin/clang-3.8 /usr/bin/clang              && \
+    ln -s /usr/bin/clang++-3.8 /usr/bin/clang++          && \
+    ln -s /usr/bin/llvm-dsymutil-3.8 /usr/bin/dsymutil   && \
+    UNATTENDED=yes OSX_VERSION_MIN=${OSX_MIN} /tmp/osxcross/build.sh && \
+    rm -rf /tmp/osxcross/target/SDK/${OSX_SDK}/usr/share && \
+    cd /tmp                                              && \
+    tar cfJ osxcross.tar.xz osxcross/target              && \
+    rm -rf /tmp/osxcross                                 && \
+    apt-get install -y                     \
+        bison curl flex gawk gcc g++ gperf help2man libncurses5-dev make patch python-dev texinfo xz-utils && \
+    curl -L http://crosstool-ng.org/download/crosstool-ng/crosstool-ng-${CTNG}.tar.xz  \
+         | tar -xJ -C /tmp/             && \
+    cd /tmp/crosstool-ng-${CTNG}        && \
+    ./configure --enable-local          && \
+    make                                && \
+    ./ct-ng x86_64-centos6-linux-gnu    && \
+    sed -i '/CT_PREFIX_DIR=/d' .config  && \
+    echo 'CT_PREFIX_DIR="/tmp/${CT_HOST:+HOST-${CT_HOST}/}${CT_TARGET}"' >> .config && \
+    echo 'CT_EXPERIMENTAL=y' >> .config && \
+    echo 'CT_ALLOW_BUILD_AS_ROOT=y' >> .config && \
+    echo 'CT_ALLOW_BUILD_AS_ROOT_SURE=y' >> .config && \
+    ./ct-ng build                       && \
+    cd /tmp                             && \
+    rm /tmp/x86_64-centos6-linux-gnu/build.log.bz2 && \
+    tar cfJ x86_64-centos6-linux-gnu.tar.xz x86_64-centos6-linux-gnu/ && \
+    rm -rf /tmp/x86_64-centos6-linux-gnu/ && \
+    rm -rf /tmp/crosstool-ng-${CTNG}
+
+# base image to crossbuild grafana
+FROM ubuntu:14.04
+
+ENV GOVERSION=1.11.5 \
+    PATH=/usr/local/go/bin:$PATH \
+    GOPATH=/go \
+    NODEVERSION=10.14.2
+
+COPY --from=toolchain /tmp/x86_64-centos6-linux-gnu.tar.xz /tmp/
+COPY --from=toolchain /tmp/osxcross.tar.xz /tmp/
+
+RUN apt-get update   && \
+    apt-get install -y  \
+        clang-3.8 gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf gcc-mingw-w64-x86-64 \
+        apt-transport-https \
+        ca-certificates \
+        curl            \
+        libfontconfig1  \
+        gcc             \
+        g++             \
+        git             \
+        make            \
+        rpm             \
+        xz-utils        \
+        expect          \
+        gnupg2          \
+        unzip        && \
+    ln -s /usr/bin/clang-3.8 /usr/bin/clang                             && \
+    ln -s /usr/bin/clang++-3.8 /usr/bin/clang++                         && \
+    ln -s /usr/bin/llvm-dsymutil-3.8 /usr/bin/dsymutil                  && \
+    curl -L https://nodejs.org/dist/v${NODEVERSION}/node-v${NODEVERSION}-linux-x64.tar.xz \
+      | tar -xJ --strip-components=1 -C /usr/local                      && \
+    curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -   && \
+    echo "deb [arch=amd64] https://dl.yarnpkg.com/debian/ stable main"     \
+      | tee /etc/apt/sources.list.d/yarn.list                           && \
+    apt-get update && apt-get install --no-install-recommends yarn      && \
+    curl -L https://storage.googleapis.com/golang/go${GOVERSION}.linux-amd64.tar.gz \
+      | tar -xz -C /usr/local
+
+RUN apt-get install -y                           \
+        gcc libc-dev make && \
+    gpg2 --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB && \
+    curl -sSL https://get.rvm.io | bash -s stable && \
+    /bin/bash -l -c "rvm requirements && rvm install 2.2 && gem install -N fpm"
+
+ADD ./bootstrap.sh /tmp/bootstrap.sh

+ 54 - 0
scripts/build/ci-build/Makefile

@@ -0,0 +1,54 @@
+VERSION="dev"
+TAG="grafana/build-container"
+USER_ID=$(shell id -u)
+GROUP_ID=$(shell id -g)
+
+all: build deploy
+
+build:
+	docker build -t "${TAG}:${VERSION}" .
+
+deploy:
+	docker push "${TAG}:${VERSION}"
+
+run:
+	docker run -ti \
+		-e "CIRCLE_BRANCH=local" \
+		-e "CIRCLE_BUILD_NUM=472" \
+		${TAG}:${VERSION} \
+		bash
+
+run-with-local-source-live:
+	docker run -d \
+		-e "CIRCLE_BRANCH=local" \
+		-e "CIRCLE_BUILD_NUM=472" \
+		-w "/go/src/github.com/grafana/grafana" \
+		--name grafana-build \
+		-v "${GOPATH}/src/github.com/grafana/grafana:/go/src/github.com/grafana/grafana" \
+		${TAG}:${VERSION} \
+		bash -c "/tmp/bootstrap.sh; mkdir /.cache; chown "${USER_ID}:${GROUP_ID}" /.cache; tail -f /dev/null"
+	docker exec -ti --user "${USER_ID}:${GROUP_ID}" grafana-build bash
+
+run-with-local-source-copy:
+	docker run -d \
+		-e "CIRCLE_BRANCH=local" \
+		-e "CIRCLE_BUILD_NUM=472" \
+		-w "/go/src/github.com/grafana/grafana" \
+		--name grafana-build \
+		${TAG}:${VERSION} \
+		bash -c "/tmp/bootstrap.sh; tail -f /dev/null"
+	docker cp "${GOPATH}/src/github.com/grafana/grafana" grafana-build:/go/src/github.com/grafana/
+	docker exec -ti grafana-build bash
+
+update-source:
+	docker cp "${GOPATH}/src/github.com/grafana/grafana" grafana-build:/go/src/github.com/grafana/	
+
+attach:
+	docker exec -ti grafana-build bash
+
+attach-live:
+	docker exec -ti --user "${USER_ID}:${GROUP_ID}" grafana-build bash
+
+stop:
+	docker kill grafana-build
+	docker rm grafana-build

+ 20 - 0
scripts/build/ci-build/README.md

@@ -0,0 +1,20 @@
+# grafana-build-container
+Grafana build container
+
+## Description
+
+This is a container for cross-platform builds of Grafana. You can run it locally using the Makefile.
+
+## Makefile targets
+
+* `make run-with-local-source-copy`
+  - Starts the container locally and copies your local sources into the container
+* `make run-with-local-source-live`
+  - Starts the container (as your user) locally and maps your Grafana project dir into the container
+* `make update-source`
+  - Updates the sources in the container from your local sources
+* `make stop`
+  - Kills the container
+* `make attach`
+  - Opens bash within the running container
+

+ 5 - 0
scripts/build/ci-build/bootstrap.sh

@@ -0,0 +1,5 @@
+#!/bin/bash
+
+cd /tmp
+tar xfJ x86_64-centos6-linux-gnu.tar.xz
+tar xfJ osxcross.tar.xz

+ 7 - 0
scripts/build/ci-build/build-deploy.sh

@@ -0,0 +1,7 @@
+#!/bin/bash
+
+_version="1.2.3"
+_tag="grafana/build-container:${_version}"
+
+docker build -t $_tag .
+docker push $_tag

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels