瀏覽代碼

Merge branch 'master' into react-panels

Torkel Ödegaard 7 年之前
父節點
當前提交
902eba90d0
共有 76 個文件被更改,包括 1346 次插入1097 次删除
  1. 5 4
      .circleci/config.yml
  2. 1 1
      package.json
  3. 1 1
      pkg/api/alerting_test.go
  4. 1 1
      pkg/api/annotations_test.go
  5. 1 1
      pkg/api/dashboard_snapshot_test.go
  6. 2 2
      pkg/api/dashboard_test.go
  7. 1 0
      pkg/api/team.go
  8. 1 1
      pkg/api/team_test.go
  9. 8 8
      pkg/models/team.go
  10. 5 5
      pkg/services/guardian/guardian.go
  11. 3 3
      pkg/services/guardian/guardian_util_test.go
  12. 0 2
      pkg/services/notifications/notifications.go
  13. 3 1
      pkg/services/rendering/phantomjs.go
  14. 28 16
      pkg/services/sqlstore/team.go
  15. 1 1
      pkg/tsdb/elasticsearch/client/client.go
  16. 1 1
      pkg/tsdb/elasticsearch/client/models.go
  17. 2 2
      public/app/containers/ManageDashboards/FolderPermissions.tsx
  18. 149 0
      public/app/containers/Teams/TeamGroupSync.tsx
  19. 125 0
      public/app/containers/Teams/TeamList.tsx
  20. 144 0
      public/app/containers/Teams/TeamMembers.tsx
  21. 77 0
      public/app/containers/Teams/TeamPages.tsx
  22. 69 0
      public/app/containers/Teams/TeamSettings.tsx
  23. 0 2
      public/app/core/angular_wrappers.ts
  24. 21 0
      public/app/core/components/Forms/Forms.tsx
  25. 24 24
      public/app/core/components/Permissions/AddPermissions.jest.tsx
  26. 23 38
      public/app/core/components/Permissions/AddPermissions.tsx
  27. 4 3
      public/app/core/components/Permissions/DashboardPermissions.tsx
  28. 1 1
      public/app/core/components/Permissions/DisabledPermissionsListItem.tsx
  29. 1 1
      public/app/core/components/Permissions/PermissionsListItem.tsx
  30. 5 5
      public/app/core/components/Picker/DescriptionPicker.tsx
  31. 14 10
      public/app/core/components/Picker/TeamPicker.jest.tsx
  32. 18 20
      public/app/core/components/Picker/TeamPicker.tsx
  33. 11 10
      public/app/core/components/Picker/UserPicker.jest.tsx
  34. 30 27
      public/app/core/components/Picker/UserPicker.tsx
  35. 0 34
      public/app/core/components/Picker/withPicker.tsx
  36. 2 1
      public/app/core/components/grafana_app.ts
  37. 0 64
      public/app/core/components/team_picker.ts
  38. 0 71
      public/app/core/components/user_picker.ts
  39. 0 4
      public/app/core/core.ts
  40. 14 0
      public/app/core/services/backend_srv.ts
  41. 1 1
      public/app/core/utils/kbn.ts
  42. 1 1
      public/app/features/dashboard/dashnav/dashnav.html
  43. 7 14
      public/app/features/dashboard/folder_picker/folder_picker.ts
  44. 0 2
      public/app/features/org/all.ts
  45. 0 105
      public/app/features/org/partials/team_details.html
  46. 0 68
      public/app/features/org/partials/teams.html
  47. 0 42
      public/app/features/org/specs/team_details_ctrl.jest.ts
  48. 0 108
      public/app/features/org/team_details_ctrl.ts
  49. 0 66
      public/app/features/org/teams_ctrl.ts
  50. 1 1
      public/app/features/panel/metrics_panel_ctrl.ts
  51. 1 1
      public/app/features/plugins/plugin_loader.ts
  52. 2 0
      public/app/features/templating/adhoc_variable.ts
  53. 2 0
      public/app/features/templating/constant_variable.ts
  54. 2 0
      public/app/features/templating/custom_variable.ts
  55. 2 0
      public/app/features/templating/datasource_variable.ts
  56. 2 0
      public/app/features/templating/interval_variable.ts
  57. 2 0
      public/app/features/templating/query_variable.ts
  58. 57 0
      public/app/features/templating/specs/template_srv.jest.ts
  59. 6 0
      public/app/features/templating/template_srv.ts
  60. 9 0
      public/app/plugins/datasource/cloudwatch/datasource.ts
  61. 92 169
      public/app/plugins/datasource/cloudwatch/specs/datasource.jest.ts
  62. 48 59
      public/app/plugins/datasource/mysql/specs/datasource.jest.ts
  63. 50 60
      public/app/plugins/datasource/postgres/specs/datasource.jest.ts
  64. 1 1
      public/app/plugins/datasource/prometheus/datasource.ts
  65. 18 18
      public/app/plugins/datasource/prometheus/specs/datasource_specs.ts
  66. 1 1
      public/app/plugins/panel/heatmap/color_scale.ts
  67. 8 1
      public/app/plugins/panel/heatmap/heatmap_ctrl.ts
  68. 13 7
      public/app/routes/routes.ts
  69. 2 1
      public/app/stores/NavStore/NavItem.ts
  70. 40 0
      public/app/stores/NavStore/NavStore.ts
  71. 4 0
      public/app/stores/RootStore/RootStore.ts
  72. 156 0
      public/app/stores/TeamsStore/TeamsStore.ts
  73. 2 2
      public/sass/components/_gf-form.scss
  74. 8 0
      public/sass/components/_navbar.scss
  75. 12 1
      public/test/jest-shim.ts
  76. 0 3
      scripts/circle-test-backend.sh

+ 5 - 4
.circleci/config.yml

@@ -88,6 +88,9 @@ jobs:
       - run:
           name: run linters
           command: 'gometalinter.v2 --enable-gc --vendor --deadline 10m --disable-all --enable=deadcode --enable=ineffassign --enable=structcheck --enable=unconvert --enable=varcheck ./...'
+      - run:
+          name: run go vet 
+          command: 'go vet ./pkg/...'
 
   test-frontend:
     docker:
@@ -243,7 +246,7 @@ workflows:
   test-and-build:
     jobs:
       - build-all:
-          filters: *filter-not-release
+          filters: *filter-only-master
       - build-enterprise:
           filters: *filter-only-master
       - codespell:
@@ -267,9 +270,7 @@ workflows:
             - gometalinter
             - mysql-integration-test
             - postgres-integration-test
-          filters:
-           branches:
-             only: master
+          filters: *filter-only-master           
       - deploy-enterprise-master:
           requires:
             - build-all

+ 1 - 1
package.json

@@ -149,7 +149,7 @@
     "classnames": "^2.2.5",
     "clipboard": "^1.7.1",
     "d3": "^4.11.0",
-    "d3-scale-chromatic": "^1.1.1",
+    "d3-scale-chromatic": "^1.3.0",
     "eventemitter3": "^2.0.3",
     "file-saver": "^1.3.3",
     "immutable": "^3.8.2",

+ 1 - 1
pkg/api/alerting_test.go

@@ -31,7 +31,7 @@ func TestAlertingApiEndpoint(t *testing.T) {
 		})
 
 		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
-			query.Result = []*m.Team{}
+			query.Result = []*m.TeamDTO{}
 			return nil
 		})
 

+ 1 - 1
pkg/api/annotations_test.go

@@ -119,7 +119,7 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
 		})
 
 		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
-			query.Result = []*m.Team{}
+			query.Result = []*m.TeamDTO{}
 			return nil
 		})
 

+ 1 - 1
pkg/api/dashboard_snapshot_test.go

@@ -39,7 +39,7 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 			return nil
 		})
 
-		teamResp := []*m.Team{}
+		teamResp := []*m.TeamDTO{}
 		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
 			query.Result = teamResp
 			return nil

+ 2 - 2
pkg/api/dashboard_test.go

@@ -61,7 +61,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		})
 
 		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
-			query.Result = []*m.Team{}
+			query.Result = []*m.TeamDTO{}
 			return nil
 		})
 
@@ -230,7 +230,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		})
 
 		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
-			query.Result = []*m.Team{}
+			query.Result = []*m.TeamDTO{}
 			return nil
 		})
 

+ 1 - 0
pkg/api/team.go

@@ -93,5 +93,6 @@ func GetTeamByID(c *m.ReqContext) Response {
 		return Error(500, "Failed to get Team", err)
 	}
 
+	query.Result.AvatarUrl = dtos.GetGravatarUrlWithDefault(query.Result.Email, query.Result.Name)
 	return JSON(200, &query.Result)
 }

+ 1 - 1
pkg/api/team_test.go

@@ -13,7 +13,7 @@ import (
 func TestTeamApiEndpoint(t *testing.T) {
 	Convey("Given two teams", t, func() {
 		mockResult := models.SearchTeamQueryResult{
-			Teams: []*models.SearchTeamDto{
+			Teams: []*models.TeamDTO{
 				{Name: "team1"},
 				{Name: "team2"},
 			},

+ 8 - 8
pkg/models/team.go

@@ -49,13 +49,13 @@ type DeleteTeamCommand struct {
 type GetTeamByIdQuery struct {
 	OrgId  int64
 	Id     int64
-	Result *Team
+	Result *TeamDTO
 }
 
 type GetTeamsByUserQuery struct {
 	OrgId  int64
-	UserId int64   `json:"userId"`
-	Result []*Team `json:"teams"`
+	UserId int64      `json:"userId"`
+	Result []*TeamDTO `json:"teams"`
 }
 
 type SearchTeamsQuery struct {
@@ -68,7 +68,7 @@ type SearchTeamsQuery struct {
 	Result SearchTeamQueryResult
 }
 
-type SearchTeamDto struct {
+type TeamDTO struct {
 	Id          int64  `json:"id"`
 	OrgId       int64  `json:"orgId"`
 	Name        string `json:"name"`
@@ -78,8 +78,8 @@ type SearchTeamDto struct {
 }
 
 type SearchTeamQueryResult struct {
-	TotalCount int64            `json:"totalCount"`
-	Teams      []*SearchTeamDto `json:"teams"`
-	Page       int              `json:"page"`
-	PerPage    int              `json:"perPage"`
+	TotalCount int64      `json:"totalCount"`
+	Teams      []*TeamDTO `json:"teams"`
+	Page       int        `json:"page"`
+	PerPage    int        `json:"perPage"`
 }

+ 5 - 5
pkg/services/guardian/guardian.go

@@ -30,7 +30,7 @@ type dashboardGuardianImpl struct {
 	dashId int64
 	orgId  int64
 	acl    []*m.DashboardAclInfoDTO
-	groups []*m.Team
+	teams  []*m.TeamDTO
 	log    log.Logger
 }
 
@@ -186,15 +186,15 @@ func (g *dashboardGuardianImpl) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
 	return g.acl, nil
 }
 
-func (g *dashboardGuardianImpl) getTeams() ([]*m.Team, error) {
-	if g.groups != nil {
-		return g.groups, nil
+func (g *dashboardGuardianImpl) getTeams() ([]*m.TeamDTO, error) {
+	if g.teams != nil {
+		return g.teams, nil
 	}
 
 	query := m.GetTeamsByUserQuery{OrgId: g.orgId, UserId: g.user.UserId}
 	err := bus.Dispatch(&query)
 
-	g.groups = query.Result
+	g.teams = query.Result
 	return query.Result, err
 }
 

+ 3 - 3
pkg/services/guardian/guardian_util_test.go

@@ -19,7 +19,7 @@ type scenarioContext struct {
 	givenUser          *m.SignedInUser
 	givenDashboardID   int64
 	givenPermissions   []*m.DashboardAclInfoDTO
-	givenTeams         []*m.Team
+	givenTeams         []*m.TeamDTO
 	updatePermissions  []*m.DashboardAcl
 	expectedFlags      permissionFlags
 	callerFile         string
@@ -84,11 +84,11 @@ func permissionScenario(desc string, dashboardID int64, sc *scenarioContext, per
 		return nil
 	})
 
-	teams := []*m.Team{}
+	teams := []*m.TeamDTO{}
 
 	for _, p := range permissions {
 		if p.TeamId > 0 {
-			teams = append(teams, &m.Team{Id: p.TeamId})
+			teams = append(teams, &m.TeamDTO{Id: p.TeamId})
 		}
 	}
 

+ 0 - 2
pkg/services/notifications/notifications.go

@@ -98,8 +98,6 @@ func (ns *NotificationService) Run(ctx context.Context) error {
 			return ctx.Err()
 		}
 	}
-
-	return nil
 }
 
 func (ns *NotificationService) SendWebhookSync(ctx context.Context, cmd *m.SendWebhookSync) error {

+ 3 - 1
pkg/services/rendering/phantomjs.go

@@ -58,7 +58,9 @@ func (rs *RenderingService) renderViaPhantomJS(ctx context.Context, opts Opts) (
 		cmdArgs = append([]string{fmt.Sprintf("--output-encoding=%s", opts.Encoding)}, cmdArgs...)
 	}
 
-	commandCtx, _ := context.WithTimeout(ctx, opts.Timeout+time.Second*2)
+	commandCtx, cancel := context.WithTimeout(ctx, opts.Timeout+time.Second*2)
+	defer cancel()
+
 	cmd := exec.CommandContext(commandCtx, binPath, cmdArgs...)
 	cmd.Stderr = cmd.Stdout
 

+ 28 - 16
pkg/services/sqlstore/team.go

@@ -22,6 +22,16 @@ func init() {
 	bus.AddHandler("sql", GetTeamMembers)
 }
 
+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
+		FROM team as team `
+}
+
 func CreateTeam(cmd *m.CreateTeamCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 
@@ -130,21 +140,15 @@ func isTeamNameTaken(orgId int64, name string, existingId int64, sess *DBSession
 
 func SearchTeams(query *m.SearchTeamsQuery) error {
 	query.Result = m.SearchTeamQueryResult{
-		Teams: make([]*m.SearchTeamDto, 0),
+		Teams: make([]*m.TeamDTO, 0),
 	}
 	queryWithWildcards := "%" + query.Query + "%"
 
 	var sql bytes.Buffer
 	params := make([]interface{}, 0)
 
-	sql.WriteString(`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
-		from team as team
-		where team.org_id = ?`)
+	sql.WriteString(getTeamSelectSqlBase())
+	sql.WriteString(` WHERE team.org_id = ?`)
 
 	params = append(params, query.OrgId)
 
@@ -186,8 +190,14 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
 }
 
 func GetTeamById(query *m.GetTeamByIdQuery) error {
-	var team m.Team
-	exists, err := x.Where("org_id=? and id=?", query.OrgId, query.Id).Get(&team)
+	var sql bytes.Buffer
+
+	sql.WriteString(getTeamSelectSqlBase())
+	sql.WriteString(` WHERE team.org_id = ? and team.id = ?`)
+
+	var team m.TeamDTO
+	exists, err := x.Sql(sql.String(), query.OrgId, query.Id).Get(&team)
+
 	if err != nil {
 		return err
 	}
@@ -202,13 +212,15 @@ func GetTeamById(query *m.GetTeamByIdQuery) error {
 
 // GetTeamsByUser is used by the Guardian when checking a users' permissions
 func GetTeamsByUser(query *m.GetTeamsByUserQuery) error {
-	query.Result = make([]*m.Team, 0)
+	query.Result = make([]*m.TeamDTO, 0)
+
+	var sql bytes.Buffer
 
-	sess := x.Table("team")
-	sess.Join("INNER", "team_member", "team.id=team_member.team_id")
-	sess.Where("team.org_id=? and team_member.user_id=?", query.OrgId, query.UserId)
+	sql.WriteString(getTeamSelectSqlBase())
+	sql.WriteString(` INNER JOIN team_member on team.id = team_member.team_id`)
+	sql.WriteString(` WHERE team.org_id = ? and team_member.user_id = ?`)
 
-	err := sess.Find(&query.Result)
+	err := x.Sql(sql.String(), query.OrgId, query.UserId).Find(&query.Result)
 	return err
 }
 

+ 1 - 1
pkg/tsdb/elasticsearch/client/client.go

@@ -218,7 +218,7 @@ func (c *baseClientImpl) ExecuteMultisearch(r *MultiSearchRequest) (*MultiSearch
 	elapsed := time.Now().Sub(start)
 	clientLog.Debug("Decoded multisearch json response", "took", elapsed)
 
-	msr.status = res.StatusCode
+	msr.Status = res.StatusCode
 
 	return &msr, nil
 }

+ 1 - 1
pkg/tsdb/elasticsearch/client/models.go

@@ -74,7 +74,7 @@ type MultiSearchRequest struct {
 
 // MultiSearchResponse represents a multi search response
 type MultiSearchResponse struct {
-	status    int               `json:"status,omitempty"`
+	Status    int               `json:"status,omitempty"`
 	Responses []*SearchResponse `json:"responses"`
 }
 

+ 2 - 2
public/app/containers/ManageDashboards/FolderPermissions.tsx

@@ -54,7 +54,7 @@ export class FolderPermissions extends Component<IContainerProps, any> {
         <PageHeader model={nav as any} />
         <div className="page-container page-body">
           <div className="page-action-bar">
-            <h2 className="d-inline-block">Folder Permissions</h2>
+            <h3 className="page-sub-heading">Folder Permissions</h3>
             <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
               <i className="gicon gicon-question gicon--has-hover" />
             </Tooltip>
@@ -68,7 +68,7 @@ export class FolderPermissions extends Component<IContainerProps, any> {
             </button>
           </div>
           <SlideDown in={permissions.isAddPermissionsVisible}>
-            <AddPermissions permissions={permissions} backendSrv={backendSrv} />
+            <AddPermissions permissions={permissions} />
           </SlideDown>
           <Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
         </div>

+ 149 - 0
public/app/containers/Teams/TeamGroupSync.tsx

@@ -0,0 +1,149 @@
+import React from 'react';
+import { hot } from 'react-hot-loader';
+import { observer } from 'mobx-react';
+import { ITeam, ITeamGroup } from 'app/stores/TeamsStore/TeamsStore';
+import SlideDown from 'app/core/components/Animations/SlideDown';
+import Tooltip from 'app/core/components/Tooltip/Tooltip';
+
+interface Props {
+  team: ITeam;
+}
+
+interface State {
+  isAdding: boolean;
+  newGroupId?: string;
+}
+
+const headerTooltip = `Sync LDAP or OAuth groups with your Grafana teams.`;
+
+@observer
+export class TeamGroupSync extends React.Component<Props, State> {
+  constructor(props) {
+    super(props);
+    this.state = { isAdding: false, newGroupId: '' };
+  }
+
+  componentDidMount() {
+    this.props.team.loadGroups();
+  }
+
+  renderGroup(group: ITeamGroup) {
+    return (
+      <tr key={group.groupId}>
+        <td>{group.groupId}</td>
+        <td style={{ width: '1%' }}>
+          <a className="btn btn-danger btn-mini" onClick={() => this.onRemoveGroup(group)}>
+            <i className="fa fa-remove" />
+          </a>
+        </td>
+      </tr>
+    );
+  }
+
+  onToggleAdding = () => {
+    this.setState({ isAdding: !this.state.isAdding });
+  };
+
+  onNewGroupIdChanged = evt => {
+    this.setState({ newGroupId: evt.target.value });
+  };
+
+  onAddGroup = () => {
+    this.props.team.addGroup(this.state.newGroupId);
+    this.setState({ isAdding: false, newGroupId: '' });
+  };
+
+  onRemoveGroup = (group: ITeamGroup) => {
+    this.props.team.removeGroup(group.groupId);
+  };
+
+  isNewGroupValid() {
+    return this.state.newGroupId.length > 1;
+  }
+
+  render() {
+    const { isAdding, newGroupId } = this.state;
+    const groups = this.props.team.groups.values();
+
+    return (
+      <div>
+        <div className="page-action-bar">
+          <h3 className="page-sub-heading">External group sync</h3>
+          <Tooltip className="page-sub-heading-icon" placement="auto" content={headerTooltip}>
+            <i className="gicon gicon-question gicon--has-hover" />
+          </Tooltip>
+          <div className="page-action-bar__spacer" />
+          {groups.length > 0 && (
+            <button className="btn btn-success pull-right" onClick={this.onToggleAdding}>
+              <i className="fa fa-plus" /> Add group
+            </button>
+          )}
+        </div>
+
+        <SlideDown in={isAdding}>
+          <div className="cta-form">
+            <button className="cta-form__close btn btn-transparent" onClick={this.onToggleAdding}>
+              <i className="fa fa-close" />
+            </button>
+            <h5>Add External Group</h5>
+            <div className="gf-form-inline">
+              <div className="gf-form">
+                <input
+                  type="text"
+                  className="gf-form-input width-30"
+                  value={newGroupId}
+                  onChange={this.onNewGroupIdChanged}
+                  placeholder="cn=ops,ou=groups,dc=grafana,dc=org"
+                />
+              </div>
+
+              <div className="gf-form">
+                <button
+                  className="btn btn-success gf-form-btn"
+                  onClick={this.onAddGroup}
+                  type="submit"
+                  disabled={!this.isNewGroupValid()}
+                >
+                  Add group
+                </button>
+              </div>
+            </div>
+          </div>
+        </SlideDown>
+
+        {groups.length === 0 &&
+          !isAdding && (
+            <div className="empty-list-cta">
+              <div className="empty-list-cta__title">There are no external groups to sync with</div>
+              <button onClick={this.onToggleAdding} className="empty-list-cta__button btn btn-xlarge btn-success">
+                <i className="gicon gicon-add-team" />
+                Add Group
+              </button>
+              <div className="empty-list-cta__pro-tip">
+                <i className="fa fa-rocket" /> {headerTooltip}
+                <a className="text-link empty-list-cta__pro-tip-link" href="asd" target="_blank">
+                  Learn more
+                </a>
+              </div>
+            </div>
+          )}
+
+        {groups.length > 0 && (
+          <div className="admin-list-table">
+            <table className="filter-table filter-table--hover form-inline">
+              <thead>
+                <tr>
+                  <th>External Group ID</th>
+                  <th style={{ width: '1%' }} />
+                </tr>
+              </thead>
+              <tbody>{groups.map(group => this.renderGroup(group))}</tbody>
+            </table>
+          </div>
+        )}
+      </div>
+    );
+  }
+}
+
+export default hot(module)(TeamGroupSync);

+ 125 - 0
public/app/containers/Teams/TeamList.tsx

@@ -0,0 +1,125 @@
+import React from 'react';
+import { hot } from 'react-hot-loader';
+import { inject, observer } from 'mobx-react';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import { NavStore } from 'app/stores/NavStore/NavStore';
+import { TeamsStore, ITeam } from 'app/stores/TeamsStore/TeamsStore';
+import { BackendSrv } from 'app/core/services/backend_srv';
+import appEvents from 'app/core/app_events';
+
+interface Props {
+  nav: typeof NavStore.Type;
+  teams: typeof TeamsStore.Type;
+  backendSrv: BackendSrv;
+}
+
+@inject('nav', 'teams')
+@observer
+export class TeamList extends React.Component<Props, any> {
+  constructor(props) {
+    super(props);
+
+    this.props.nav.load('cfg', 'teams');
+    this.fetchTeams();
+  }
+
+  fetchTeams() {
+    this.props.teams.loadTeams();
+  }
+
+  deleteTeam(team: ITeam) {
+    appEvents.emit('confirm-modal', {
+      title: 'Delete',
+      text: 'Are you sure you want to delete Team ' + team.name + '?',
+      yesText: 'Delete',
+      icon: 'fa-warning',
+      onConfirm: () => {
+        this.deleteTeamConfirmed(team);
+      },
+    });
+  }
+
+  deleteTeamConfirmed(team) {
+    this.props.backendSrv.delete('/api/teams/' + team.id).then(this.fetchTeams.bind(this));
+  }
+
+  onSearchQueryChange = evt => {
+    this.props.teams.setSearchQuery(evt.target.value);
+  };
+
+  renderTeamMember(team: ITeam): JSX.Element {
+    let teamUrl = `org/teams/edit/${team.id}`;
+
+    return (
+      <tr key={team.id}>
+        <td className="width-4 text-center link-td">
+          <a href={teamUrl}>
+            <img className="filter-table__avatar" src={team.avatarUrl} />
+          </a>
+        </td>
+        <td className="link-td">
+          <a href={teamUrl}>{team.name}</a>
+        </td>
+        <td className="link-td">
+          <a href={teamUrl}>{team.email}</a>
+        </td>
+        <td className="link-td">
+          <a href={teamUrl}>{team.memberCount}</a>
+        </td>
+        <td className="text-right">
+          <a onClick={() => this.deleteTeam(team)} className="btn btn-danger btn-small">
+            <i className="fa fa-remove" />
+          </a>
+        </td>
+      </tr>
+    );
+  }
+
+  render() {
+    const { nav, teams } = this.props;
+    return (
+      <div>
+        <PageHeader model={nav as any} />
+        <div className="page-container page-body">
+          <div className="page-action-bar">
+            <div className="gf-form gf-form--grow">
+              <label className="gf-form--has-input-icon gf-form--grow">
+                <input
+                  type="text"
+                  className="gf-form-input"
+                  placeholder="Search teams"
+                  value={teams.search}
+                  onChange={this.onSearchQueryChange}
+                />
+                <i className="gf-form-input-icon fa fa-search" />
+              </label>
+            </div>
+
+            <div className="page-action-bar__spacer" />
+
+            <a className="btn btn-success" href="org/teams/new">
+              <i className="fa fa-plus" /> 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={{ width: '1%' }} />
+                </tr>
+              </thead>
+              <tbody>{teams.filteredTeams.map(team => this.renderTeamMember(team))}</tbody>
+            </table>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default hot(module)(TeamList);

+ 144 - 0
public/app/containers/Teams/TeamMembers.tsx

@@ -0,0 +1,144 @@
+import React from 'react';
+import { hot } from 'react-hot-loader';
+import { observer } from 'mobx-react';
+import { ITeam, ITeamMember } from 'app/stores/TeamsStore/TeamsStore';
+import appEvents from 'app/core/app_events';
+import SlideDown from 'app/core/components/Animations/SlideDown';
+import { UserPicker, User } from 'app/core/components/Picker/UserPicker';
+
+interface Props {
+  team: ITeam;
+}
+
+interface State {
+  isAdding: boolean;
+  newTeamMember?: User;
+}
+
+@observer
+export class TeamMembers extends React.Component<Props, State> {
+  constructor(props) {
+    super(props);
+    this.state = { isAdding: false, newTeamMember: null };
+  }
+
+  componentDidMount() {
+    this.props.team.loadMembers();
+  }
+
+  onSearchQueryChange = evt => {
+    this.props.team.setSearchQuery(evt.target.value);
+  };
+
+  removeMember(member: ITeamMember) {
+    appEvents.emit('confirm-modal', {
+      title: 'Remove Member',
+      text: 'Are you sure you want to remove ' + member.login + ' from this group?',
+      yesText: 'Remove',
+      icon: 'fa-warning',
+      onConfirm: () => {
+        this.removeMemberConfirmed(member);
+      },
+    });
+  }
+
+  removeMemberConfirmed(member: ITeamMember) {
+    this.props.team.removeMember(member);
+  }
+
+  renderMember(member: ITeamMember) {
+    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>
+        <td style={{ width: '1%' }}>
+          <a onClick={() => this.removeMember(member)} className="btn btn-danger btn-mini">
+            <i className="fa fa-remove" />
+          </a>
+        </td>
+      </tr>
+    );
+  }
+
+  onToggleAdding = () => {
+    this.setState({ isAdding: !this.state.isAdding });
+  };
+
+  onUserSelected = (user: User) => {
+    this.setState({ newTeamMember: user });
+  };
+
+  onAddUserToTeam = async () => {
+    await this.props.team.addMember(this.state.newTeamMember.id);
+    await this.props.team.loadMembers();
+    this.setState({ newTeamMember: null });
+  };
+
+  render() {
+    const { newTeamMember, isAdding } = this.state;
+    const members = this.props.team.members.values();
+    const newTeamMemberValue = newTeamMember && newTeamMember.id.toString();
+
+    return (
+      <div>
+        <div className="page-action-bar">
+          <div className="gf-form gf-form--grow">
+            <label className="gf-form--has-input-icon gf-form--grow">
+              <input
+                type="text"
+                className="gf-form-input"
+                placeholder="Search members"
+                value={''}
+                onChange={this.onSearchQueryChange}
+              />
+              <i className="gf-form-input-icon fa fa-search" />
+            </label>
+          </div>
+
+          <div className="page-action-bar__spacer" />
+
+          <button className="btn btn-success pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
+            <i className="fa fa-plus" /> Add a member
+          </button>
+        </div>
+
+        <SlideDown in={isAdding}>
+          <div className="cta-form">
+            <button className="cta-form__close btn btn-transparent" onClick={this.onToggleAdding}>
+              <i className="fa fa-close" />
+            </button>
+            <h5>Add Team Member</h5>
+            <div className="gf-form-inline">
+              <UserPicker onSelected={this.onUserSelected} className="width-30" value={newTeamMemberValue} />
+
+              {this.state.newTeamMember && (
+                <button className="btn btn-success gf-form-btn" type="submit" onClick={this.onAddUserToTeam}>
+                  Add to team
+                </button>
+              )}
+            </div>
+          </div>
+        </SlideDown>
+
+        <div className="admin-list-table">
+          <table className="filter-table filter-table--hover form-inline">
+            <thead>
+              <tr>
+                <th />
+                <th>Name</th>
+                <th>Email</th>
+                <th style={{ width: '1%' }} />
+              </tr>
+            </thead>
+            <tbody>{members.map(member => this.renderMember(member))}</tbody>
+          </table>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default hot(module)(TeamMembers);

+ 77 - 0
public/app/containers/Teams/TeamPages.tsx

@@ -0,0 +1,77 @@
+import React from 'react';
+import _ from 'lodash';
+import { hot } from 'react-hot-loader';
+import { inject, observer } from 'mobx-react';
+import config from 'app/core/config';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import { NavStore } from 'app/stores/NavStore/NavStore';
+import { TeamsStore, ITeam } from 'app/stores/TeamsStore/TeamsStore';
+import { ViewStore } from 'app/stores/ViewStore/ViewStore';
+import TeamMembers from './TeamMembers';
+import TeamSettings from './TeamSettings';
+import TeamGroupSync from './TeamGroupSync';
+
+interface Props {
+  nav: typeof NavStore.Type;
+  teams: typeof TeamsStore.Type;
+  view: typeof ViewStore.Type;
+}
+
+@inject('nav', 'teams', 'view')
+@observer
+export class TeamPages extends React.Component<Props, any> {
+  isSyncEnabled: boolean;
+  currentPage: string;
+
+  constructor(props) {
+    super(props);
+
+    this.isSyncEnabled = config.buildInfo.isEnterprise;
+    this.currentPage = this.getCurrentPage();
+
+    this.loadTeam();
+  }
+
+  async loadTeam() {
+    const { teams, nav, view } = this.props;
+
+    await teams.loadById(view.routeParams.get('id'));
+
+    nav.initTeamPage(this.getCurrentTeam(), this.currentPage, this.isSyncEnabled);
+  }
+
+  getCurrentTeam(): ITeam {
+    const { teams, view } = this.props;
+    return teams.map.get(view.routeParams.get('id'));
+  }
+
+  getCurrentPage() {
+    const pages = ['members', 'settings', 'groupsync'];
+    const currentPage = this.props.view.routeParams.get('page');
+    return _.includes(pages, currentPage) ? currentPage : pages[0];
+  }
+
+  render() {
+    const { nav } = this.props;
+    const currentTeam = this.getCurrentTeam();
+
+    if (!nav.main) {
+      return null;
+    }
+
+    return (
+      <div>
+        <PageHeader model={nav as any} />
+        {currentTeam && (
+          <div className="page-container page-body">
+            {this.currentPage === 'members' && <TeamMembers team={currentTeam} />}
+            {this.currentPage === 'settings' && <TeamSettings team={currentTeam} />}
+            {this.currentPage === 'groupsync' && this.isSyncEnabled && <TeamGroupSync team={currentTeam} />}
+          </div>
+        )}
+      </div>
+    );
+  }
+}
+
+export default hot(module)(TeamPages);

+ 69 - 0
public/app/containers/Teams/TeamSettings.tsx

@@ -0,0 +1,69 @@
+import React from 'react';
+import { hot } from 'react-hot-loader';
+import { observer } from 'mobx-react';
+import { ITeam } from 'app/stores/TeamsStore/TeamsStore';
+import { Label } from 'app/core/components/Forms/Forms';
+
+interface Props {
+  team: ITeam;
+}
+
+@observer
+export class TeamSettings extends React.Component<Props, any> {
+  constructor(props) {
+    super(props);
+  }
+
+  onChangeName = evt => {
+    this.props.team.setName(evt.target.value);
+  };
+
+  onChangeEmail = evt => {
+    this.props.team.setEmail(evt.target.value);
+  };
+
+  onUpdate = evt => {
+    evt.preventDefault();
+    this.props.team.update();
+  };
+
+  render() {
+    return (
+      <div>
+        <h3 className="page-sub-heading">Team Settings</h3>
+        <form name="teamDetailsForm" className="gf-form-group">
+          <div className="gf-form max-width-30">
+            <Label>Name</Label>
+            <input
+              type="text"
+              required
+              value={this.props.team.name}
+              className="gf-form-input max-width-22"
+              onChange={this.onChangeName}
+            />
+          </div>
+          <div className="gf-form max-width-30">
+            <Label tooltip="This is optional and is primarily used to set the team profile avatar (via gravatar service)">
+              Email
+            </Label>
+            <input
+              type="email"
+              className="gf-form-input max-width-22"
+              value={this.props.team.email}
+              placeholder="team@email.com"
+              onChange={this.onChangeEmail}
+            />
+          </div>
+
+          <div className="gf-form-button-row">
+            <button type="submit" className="btn btn-success" onClick={this.onUpdate}>
+              Update
+            </button>
+          </div>
+        </form>
+      </div>
+    );
+  }
+}
+
+export default hot(module)(TeamSettings);

+ 0 - 2
public/app/core/angular_wrappers.ts

@@ -5,7 +5,6 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
 import LoginBackground from './components/Login/LoginBackground';
 import { SearchResult } from './components/search/SearchResult';
 import { TagFilter } from './components/TagFilter/TagFilter';
-import UserPicker from './components/Picker/UserPicker';
 import DashboardPermissions from './components/Permissions/DashboardPermissions';
 
 export function registerAngularDirectives() {
@@ -19,6 +18,5 @@ export function registerAngularDirectives() {
     ['onSelect', { watchDepth: 'reference' }],
     ['tagOptions', { watchDepth: 'reference' }],
   ]);
-  react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
   react2AngularDirective('dashboardPermissions', DashboardPermissions, ['backendSrv', 'dashboardId', 'folder']);
 }

+ 21 - 0
public/app/core/components/Forms/Forms.tsx

@@ -0,0 +1,21 @@
+import React, { SFC, ReactNode } from 'react';
+import Tooltip from '../Tooltip/Tooltip';
+
+interface Props {
+  tooltip?: string;
+  for?: string;
+  children: ReactNode;
+}
+
+export const Label: SFC<Props> = props => {
+  return (
+    <span className="gf-form-label width-10">
+      <span>{props.children}</span>
+      {props.tooltip && (
+        <Tooltip className="gf-form-help-icon--right-normal" placement="auto" content="hello">
+          <i className="gicon gicon-question gicon--has-hover" />
+        </Tooltip>
+      )}
+    </span>
+  );
+};

+ 24 - 24
public/app/core/components/Permissions/AddPermissions.jest.tsx

@@ -1,32 +1,32 @@
-import React from 'react';
+import React from 'react';
+import { shallow } from 'enzyme';
 import AddPermissions from './AddPermissions';
 import { RootStore } from 'app/stores/RootStore/RootStore';
-import { backendSrv } from 'test/mocks/common';
-import { shallow } from 'enzyme';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+
+jest.mock('app/core/services/backend_srv', () => ({
+  getBackendSrv: () => {
+    return {
+      get: () => {
+        return Promise.resolve([
+          { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
+          { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
+        ]);
+      },
+      post: jest.fn(() => Promise.resolve({})),
+    };
+  },
+}));
 
 describe('AddPermissions', () => {
   let wrapper;
   let store;
   let instance;
+  let backendSrv: any = getBackendSrv();
 
   beforeAll(() => {
-    backendSrv.get.mockReturnValue(
-      Promise.resolve([
-        { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
-        { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
-      ])
-    );
-
-    backendSrv.post = jest.fn(() => Promise.resolve({}));
-
-    store = RootStore.create(
-      {},
-      {
-        backendSrv: backendSrv,
-      }
-    );
-
-    wrapper = shallow(<AddPermissions permissions={store.permissions} backendSrv={backendSrv} />);
+    store = RootStore.create({}, { backendSrv: backendSrv });
+    wrapper = shallow(<AddPermissions permissions={store.permissions} />);
     instance = wrapper.instance();
     return store.permissions.load(1, true, false);
   });
@@ -43,8 +43,8 @@ describe('AddPermissions', () => {
         login: 'user2',
       };
 
-      instance.typeChanged(evt);
-      instance.userPicked(userItem);
+      instance.onTypeChanged(evt);
+      instance.onUserSelected(userItem);
 
       wrapper.update();
 
@@ -70,8 +70,8 @@ describe('AddPermissions', () => {
         name: 'ug1',
       };
 
-      instance.typeChanged(evt);
-      instance.teamPicked(teamItem);
+      instance.onTypeChanged(evt);
+      instance.onTeamSelected(teamItem);
 
       wrapper.update();
 

+ 23 - 38
public/app/core/components/Permissions/AddPermissions.tsx

@@ -1,24 +1,19 @@
-import React, { Component } from 'react';
+import React, { Component } from 'react';
 import { observer } from 'mobx-react';
 import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
-import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
-import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
+import { UserPicker, User } from 'app/core/components/Picker/UserPicker';
+import { TeamPicker, Team } from 'app/core/components/Picker/TeamPicker';
 import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
 import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
 
-export interface IProps {
+export interface Props {
   permissions: any;
-  backendSrv: any;
 }
+
 @observer
-class AddPermissions extends Component<IProps, any> {
+class AddPermissions extends Component<Props, any> {
   constructor(props) {
     super(props);
-    this.userPicked = this.userPicked.bind(this);
-    this.teamPicked = this.teamPicked.bind(this);
-    this.permissionPicked = this.permissionPicked.bind(this);
-    this.typeChanged = this.typeChanged.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
   }
 
   componentWillMount() {
@@ -26,49 +21,49 @@ class AddPermissions extends Component<IProps, any> {
     permissions.resetNewType();
   }
 
-  typeChanged(evt) {
+  onTypeChanged = evt => {
     const { value } = evt.target;
     const { permissions } = this.props;
 
     permissions.setNewType(value);
-  }
+  };
 
-  userPicked(user: User) {
+  onUserSelected = (user: User) => {
     const { permissions } = this.props;
     if (!user) {
       permissions.newItem.setUser(null, null);
       return;
     }
     return permissions.newItem.setUser(user.id, user.login, user.avatarUrl);
-  }
+  };
 
-  teamPicked(team: Team) {
+  onTeamSelected = (team: Team) => {
     const { permissions } = this.props;
     if (!team) {
       permissions.newItem.setTeam(null, null);
       return;
     }
     return permissions.newItem.setTeam(team.id, team.name, team.avatarUrl);
-  }
+  };
 
-  permissionPicked(permission: OptionWithDescription) {
+  onPermissionChanged = (permission: OptionWithDescription) => {
     const { permissions } = this.props;
     return permissions.newItem.setPermission(permission.value);
-  }
+  };
 
   resetNewType() {
     const { permissions } = this.props;
     return permissions.resetNewType();
   }
 
-  handleSubmit(evt) {
+  onSubmit = evt => {
     evt.preventDefault();
     const { permissions } = this.props;
     permissions.addStoreItem();
-  }
+  };
 
   render() {
-    const { permissions, backendSrv } = this.props;
+    const { permissions } = this.props;
     const newItem = permissions.newItem;
     const pickerClassName = 'width-20';
 
@@ -79,12 +74,12 @@ class AddPermissions extends Component<IProps, any> {
         <button className="cta-form__close btn btn-transparent" onClick={permissions.hideAddPermissions}>
           <i className="fa fa-close" />
         </button>
-        <form name="addPermission" onSubmit={this.handleSubmit}>
-          <h6>Add Permission For</h6>
+        <form name="addPermission" onSubmit={this.onSubmit}>
+          <h5>Add Permission For</h5>
           <div className="gf-form-inline">
             <div className="gf-form">
               <div className="gf-form-select-wrapper">
-                <select className="gf-form-input gf-size-auto" value={newItem.type} onChange={this.typeChanged}>
+                <select className="gf-form-input gf-size-auto" value={newItem.type} onChange={this.onTypeChanged}>
                   {aclTypes.map((option, idx) => {
                     return (
                       <option key={idx} value={option.value}>
@@ -98,30 +93,20 @@ class AddPermissions extends Component<IProps, any> {
 
             {newItem.type === 'User' ? (
               <div className="gf-form">
-                <UserPicker
-                  backendSrv={backendSrv}
-                  handlePicked={this.userPicked}
-                  value={newItem.userId}
-                  className={pickerClassName}
-                />
+                <UserPicker onSelected={this.onUserSelected} value={newItem.userId} className={pickerClassName} />
               </div>
             ) : null}
 
             {newItem.type === 'Group' ? (
               <div className="gf-form">
-                <TeamPicker
-                  backendSrv={backendSrv}
-                  handlePicked={this.teamPicked}
-                  value={newItem.teamId}
-                  className={pickerClassName}
-                />
+                <TeamPicker onSelected={this.onTeamSelected} value={newItem.teamId} className={pickerClassName} />
               </div>
             ) : null}
 
             <div className="gf-form">
               <DescriptionPicker
                 optionsWithDesc={permissionOptions}
-                handlePicked={this.permissionPicked}
+                onSelected={this.onPermissionChanged}
                 value={newItem.permission}
                 disabled={false}
                 className={'gf-form-input--form-dropdown-right'}

+ 4 - 3
public/app/core/components/Permissions/DashboardPermissions.tsx

@@ -8,13 +8,14 @@ import AddPermissions from 'app/core/components/Permissions/AddPermissions';
 import SlideDown from 'app/core/components/Animations/SlideDown';
 import { FolderInfo } from './FolderInfo';
 
-export interface IProps {
+export interface Props {
   dashboardId: number;
   folder?: FolderInfo;
   backendSrv: any;
 }
+
 @observer
-class DashboardPermissions extends Component<IProps, any> {
+class DashboardPermissions extends Component<Props, any> {
   permissions: any;
 
   constructor(props) {
@@ -53,7 +54,7 @@ class DashboardPermissions extends Component<IProps, any> {
           </div>
         </div>
         <SlideDown in={this.permissions.isAddPermissionsVisible}>
-          <AddPermissions permissions={this.permissions} backendSrv={backendSrv} />
+          <AddPermissions permissions={this.permissions} />
         </SlideDown>
         <Permissions
           permissions={this.permissions}

+ 1 - 1
public/app/core/components/Permissions/DisabledPermissionsListItem.tsx

@@ -25,7 +25,7 @@ export default class DisabledPermissionListItem extends Component<IProps, any> {
           <div className="gf-form">
             <DescriptionPicker
               optionsWithDesc={permissionOptions}
-              handlePicked={() => {}}
+              onSelected={() => {}}
               value={item.permission}
               disabled={true}
               className={'gf-form-input--form-dropdown-right'}

+ 1 - 1
public/app/core/components/Permissions/PermissionsListItem.tsx

@@ -68,7 +68,7 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
         <div className="gf-form">
           <DescriptionPicker
             optionsWithDesc={permissionOptions}
-            handlePicked={handleChangePermission}
+            onSelected={handleChangePermission}
             value={item.permission}
             disabled={item.inherited}
             className={'gf-form-input--form-dropdown-right'}

+ 5 - 5
public/app/core/components/Picker/DescriptionPicker.tsx

@@ -2,9 +2,9 @@ import React, { Component } from 'react';
 import Select from 'react-select';
 import DescriptionOption from './DescriptionOption';
 
-export interface IProps {
+export interface Props {
   optionsWithDesc: OptionWithDescription[];
-  handlePicked: (permission) => void;
+  onSelected: (permission) => void;
   value: number;
   disabled: boolean;
   className?: string;
@@ -16,14 +16,14 @@ export interface OptionWithDescription {
   description: string;
 }
 
-class DescriptionPicker extends Component<IProps, any> {
+class DescriptionPicker extends Component<Props, any> {
   constructor(props) {
     super(props);
     this.state = {};
   }
 
   render() {
-    const { optionsWithDesc, handlePicked, value, disabled, className } = this.props;
+    const { optionsWithDesc, onSelected, value, disabled, className } = this.props;
 
     return (
       <div className="permissions-picker">
@@ -34,7 +34,7 @@ class DescriptionPicker extends Component<IProps, any> {
           clearable={false}
           labelKey="label"
           options={optionsWithDesc}
-          onChange={handlePicked}
+          onChange={onSelected}
           className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           optionComponent={DescriptionOption}
           placeholder="Choose"

+ 14 - 10
public/app/core/components/Picker/TeamPicker.jest.tsx

@@ -1,19 +1,23 @@
-import React from 'react';
+import React from 'react';
 import renderer from 'react-test-renderer';
-import TeamPicker from './TeamPicker';
+import { TeamPicker } from './TeamPicker';
 
-const model = {
-  backendSrv: {
-    get: () => {
-      return new Promise((resolve, reject) => {});
-    },
+jest.mock('app/core/services/backend_srv', () => ({
+  getBackendSrv: () => {
+    return {
+      get: () => {
+        return Promise.resolve([]);
+      },
+    };
   },
-  handlePicked: () => {},
-};
+}));
 
 describe('TeamPicker', () => {
   it('renders correctly', () => {
-    const tree = renderer.create(<TeamPicker {...model} />).toJSON();
+    const props = {
+      onSelected: () => {},
+    };
+    const tree = renderer.create(<TeamPicker {...props} />).toJSON();
     expect(tree).toMatchSnapshot();
   });
 });

+ 18 - 20
public/app/core/components/Picker/TeamPicker.tsx

@@ -1,18 +1,19 @@
-import React, { Component } from 'react';
+import React, { Component } from 'react';
 import Select from 'react-select';
 import PickerOption from './PickerOption';
-import withPicker from './withPicker';
 import { debounce } from 'lodash';
+import { getBackendSrv } from 'app/core/services/backend_srv';
 
-export interface IProps {
-  backendSrv: any;
-  isLoading: boolean;
-  toggleLoading: any;
-  handlePicked: (user) => void;
+export interface Props {
+  onSelected: (team: Team) => void;
   value?: string;
   className?: string;
 }
 
+export interface State {
+  isLoading;
+}
+
 export interface Team {
   id: number;
   label: string;
@@ -20,13 +21,12 @@ export interface Team {
   avatarUrl: string;
 }
 
-class TeamPicker extends Component<IProps, any> {
+export class TeamPicker extends Component<Props, State> {
   debouncedSearch: any;
-  backendSrv: any;
 
   constructor(props) {
     super(props);
-    this.state = {};
+    this.state = { isLoading: false };
     this.search = this.search.bind(this);
 
     this.debouncedSearch = debounce(this.search, 300, {
@@ -36,9 +36,9 @@ class TeamPicker extends Component<IProps, any> {
   }
 
   search(query?: string) {
-    const { toggleLoading, backendSrv } = this.props;
+    const backendSrv = getBackendSrv();
+    this.setState({ isLoading: true });
 
-    toggleLoading(true);
     return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
       const teams = result.teams.map(team => {
         return {
@@ -49,18 +49,18 @@ class TeamPicker extends Component<IProps, any> {
         };
       });
 
-      toggleLoading(false);
+      this.setState({ isLoading: false });
       return { options: teams };
     });
   }
 
   render() {
-    const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
-    const { isLoading, handlePicked, value, className } = this.props;
+    const { onSelected, value, className } = this.props;
+    const { isLoading } = this.state;
 
     return (
       <div className="user-picker">
-        <AsyncComponent
+        <Select.Async
           valueKey="id"
           multi={false}
           labelKey="label"
@@ -69,10 +69,10 @@ class TeamPicker extends Component<IProps, any> {
           loadOptions={this.debouncedSearch}
           loadingPlaceholder="Loading..."
           noResultsText="No teams found"
-          onChange={handlePicked}
+          onChange={onSelected}
           className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           optionComponent={PickerOption}
-          placeholder="Choose"
+          placeholder="Select a team"
           value={value}
           autosize={true}
         />
@@ -80,5 +80,3 @@ class TeamPicker extends Component<IProps, any> {
     );
   }
 }
-
-export default withPicker(TeamPicker);

+ 11 - 10
public/app/core/components/Picker/UserPicker.jest.tsx

@@ -1,19 +1,20 @@
-import React from 'react';
+import React from 'react';
 import renderer from 'react-test-renderer';
-import UserPicker from './UserPicker';
+import { UserPicker } from './UserPicker';
 
-const model = {
-  backendSrv: {
-    get: () => {
-      return new Promise((resolve, reject) => {});
-    },
+jest.mock('app/core/services/backend_srv', () => ({
+  getBackendSrv: () => {
+    return {
+      get: () => {
+        return Promise.resolve([]);
+      },
+    };
   },
-  handlePicked: () => {},
-};
+}));
 
 describe('UserPicker', () => {
   it('renders correctly', () => {
-    const tree = renderer.create(<UserPicker {...model} />).toJSON();
+    const tree = renderer.create(<UserPicker onSelected={() => {}} />).toJSON();
     expect(tree).toMatchSnapshot();
   });
 });

+ 30 - 27
public/app/core/components/Picker/UserPicker.tsx

@@ -1,18 +1,19 @@
 import React, { Component } from 'react';
 import Select from 'react-select';
 import PickerOption from './PickerOption';
-import withPicker from './withPicker';
 import { debounce } from 'lodash';
+import { getBackendSrv } from 'app/core/services/backend_srv';
 
-export interface IProps {
-  backendSrv: any;
-  isLoading: boolean;
-  toggleLoading: any;
-  handlePicked: (user) => void;
+export interface Props {
+  onSelected: (user: User) => void;
   value?: string;
   className?: string;
 }
 
+export interface State {
+  isLoading: boolean;
+}
+
 export interface User {
   id: number;
   label: string;
@@ -20,13 +21,12 @@ export interface User {
   login: string;
 }
 
-class UserPicker extends Component<IProps, any> {
+export class UserPicker extends Component<Props, State> {
   debouncedSearch: any;
-  backendSrv: any;
 
   constructor(props) {
     super(props);
-    this.state = {};
+    this.state = { isLoading: false };
     this.search = this.search.bind(this);
 
     this.debouncedSearch = debounce(this.search, 300, {
@@ -36,29 +36,34 @@ class UserPicker extends Component<IProps, any> {
   }
 
   search(query?: string) {
-    const { toggleLoading, backendSrv } = this.props;
+    const backendSrv = getBackendSrv();
 
-    toggleLoading(true);
-    return backendSrv.get(`/api/org/users?query=${query}&limit=10`).then(result => {
-      const users = result.map(user => {
+    this.setState({ isLoading: true });
+
+    return backendSrv
+      .get(`/api/org/users?query=${query}&limit=10`)
+      .then(result => {
         return {
-          id: user.userId,
-          label: `${user.login} - ${user.email}`,
-          avatarUrl: user.avatarUrl,
-          login: user.login,
+          options: result.map(user => ({
+            id: user.userId,
+            label: `${user.login} - ${user.email}`,
+            avatarUrl: user.avatarUrl,
+            login: user.login,
+          })),
         };
+      })
+      .finally(() => {
+        this.setState({ isLoading: false });
       });
-      toggleLoading(false);
-      return { options: users };
-    });
   }
 
   render() {
-    const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
-    const { isLoading, handlePicked, value, className } = this.props;
+    const { value, className } = this.props;
+    const { isLoading } = this.state;
+
     return (
       <div className="user-picker">
-        <AsyncComponent
+        <Select.Async
           valueKey="id"
           multi={false}
           labelKey="label"
@@ -67,10 +72,10 @@ class UserPicker extends Component<IProps, any> {
           loadOptions={this.debouncedSearch}
           loadingPlaceholder="Loading..."
           noResultsText="No users found"
-          onChange={handlePicked}
+          onChange={this.props.onSelected}
           className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           optionComponent={PickerOption}
-          placeholder="Choose"
+          placeholder="Select user"
           value={value}
           autosize={true}
         />
@@ -78,5 +83,3 @@ class UserPicker extends Component<IProps, any> {
     );
   }
 }
-
-export default withPicker(UserPicker);

+ 0 - 34
public/app/core/components/Picker/withPicker.tsx

@@ -1,34 +0,0 @@
-import React, { Component } from 'react';
-
-export interface IProps {
-  backendSrv: any;
-  handlePicked: (data) => void;
-  value?: string;
-  className?: string;
-}
-
-export default function withPicker(WrappedComponent) {
-  return class WithPicker extends Component<IProps, any> {
-    constructor(props) {
-      super(props);
-      this.toggleLoading = this.toggleLoading.bind(this);
-
-      this.state = {
-        isLoading: false,
-      };
-    }
-
-    toggleLoading(isLoading) {
-      this.setState(prevState => {
-        return {
-          ...prevState,
-          isLoading: isLoading,
-        };
-      });
-    }
-
-    render() {
-      return <WrappedComponent toggleLoading={this.toggleLoading} isLoading={this.state.isLoading} {...this.props} />;
-    }
-  };
-}

+ 2 - 1
public/app/core/components/grafana_app.ts

@@ -8,7 +8,7 @@ import appEvents from 'app/core/app_events';
 import Drop from 'tether-drop';
 import { createStore } from 'app/stores/store';
 import colors from 'app/core/utils/colors';
-import { BackendSrv } from 'app/core/services/backend_srv';
+import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv';
 import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { AngularLoader, setAngularLoader } from 'app/core/services/angular_loader';
 
@@ -28,6 +28,7 @@ export class GrafanaCtrl {
   ) {
     // make angular loader service available to react components
     setAngularLoader(angularLoader);
+    setBackendSrv(backendSrv);
     // create store with env services
     createStore({ backendSrv, datasourceSrv });
 

+ 0 - 64
public/app/core/components/team_picker.ts

@@ -1,64 +0,0 @@
-import coreModule from 'app/core/core_module';
-import _ from 'lodash';
-
-const template = `
-<div class="dropdown">
-  <gf-form-dropdown model="ctrl.group"
-                    get-options="ctrl.debouncedSearchGroups($query)"
-                    css-class="gf-size-auto"
-                    on-change="ctrl.onChange($option)"
-  </gf-form-dropdown>
-</div>
-`;
-export class TeamPickerCtrl {
-  group: any;
-  teamPicked: any;
-  debouncedSearchGroups: any;
-
-  /** @ngInject */
-  constructor(private backendSrv) {
-    this.debouncedSearchGroups = _.debounce(this.searchGroups, 500, {
-      leading: true,
-      trailing: false,
-    });
-    this.reset();
-  }
-
-  reset() {
-    this.group = { text: 'Choose', value: null };
-  }
-
-  searchGroups(query: string) {
-    return Promise.resolve(
-      this.backendSrv.get('/api/teams/search?perpage=10&page=1&query=' + query).then(result => {
-        return _.map(result.teams, ug => {
-          return { text: ug.name, value: ug };
-        });
-      })
-    );
-  }
-
-  onChange(option) {
-    this.teamPicked({ $group: option.value });
-  }
-}
-
-export function teamPicker() {
-  return {
-    restrict: 'E',
-    template: template,
-    controller: TeamPickerCtrl,
-    bindToController: true,
-    controllerAs: 'ctrl',
-    scope: {
-      teamPicked: '&',
-    },
-    link: function(scope, elem, attrs, ctrl) {
-      scope.$on('team-picker-reset', () => {
-        ctrl.reset();
-      });
-    },
-  };
-}
-
-coreModule.directive('teamPicker', teamPicker);

+ 0 - 71
public/app/core/components/user_picker.ts

@@ -1,71 +0,0 @@
-import coreModule from 'app/core/core_module';
-import _ from 'lodash';
-
-const template = `
-<div class="dropdown">
-  <gf-form-dropdown model="ctrl.user"
-                    get-options="ctrl.debouncedSearchUsers($query)"
-                    css-class="gf-size-auto"
-                    on-change="ctrl.onChange($option)"
-  </gf-form-dropdown>
-</div>
-`;
-export class UserPickerCtrl {
-  user: any;
-  debouncedSearchUsers: any;
-  userPicked: any;
-
-  /** @ngInject */
-  constructor(private backendSrv) {
-    this.reset();
-    this.debouncedSearchUsers = _.debounce(this.searchUsers, 500, {
-      leading: true,
-      trailing: false,
-    });
-  }
-
-  searchUsers(query: string) {
-    return Promise.resolve(
-      this.backendSrv.get('/api/users/search?perpage=10&page=1&query=' + query).then(result => {
-        return _.map(result.users, user => {
-          return { text: user.login + ' -  ' + user.email, value: user };
-        });
-      })
-    );
-  }
-
-  onChange(option) {
-    this.userPicked({ $user: option.value });
-  }
-
-  reset() {
-    this.user = { text: 'Choose', value: null };
-  }
-}
-
-export interface User {
-  id: number;
-  name: string;
-  login: string;
-  email: string;
-}
-
-export function userPicker() {
-  return {
-    restrict: 'E',
-    template: template,
-    controller: UserPickerCtrl,
-    bindToController: true,
-    controllerAs: 'ctrl',
-    scope: {
-      userPicked: '&',
-    },
-    link: function(scope, elem, attrs, ctrl) {
-      scope.$on('user-picker-reset', () => {
-        ctrl.reset();
-      });
-    },
-  };
-}
-
-coreModule.directive('userPicker', userPicker);

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

@@ -44,8 +44,6 @@ import { KeybindingSrv } from './services/keybindingSrv';
 import { helpModal } from './components/help/help';
 import { JsonExplorer } from './components/json_explorer/json_explorer';
 import { NavModelSrv, NavModel } from './nav_model_srv';
-import { userPicker } from './components/user_picker';
-import { teamPicker } from './components/team_picker';
 import { geminiScrollbar } from './components/scroll/scroll';
 import { pageScrollbar } from './components/scroll/page_scroll';
 import { gfPageDirective } from './components/gf_page';
@@ -83,8 +81,6 @@ export {
   JsonExplorer,
   NavModelSrv,
   NavModel,
-  userPicker,
-  teamPicker,
   geminiScrollbar,
   pageScrollbar,
   gfPageDirective,

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

@@ -368,3 +368,17 @@ export class BackendSrv {
 }
 
 coreModule.service('backendSrv', BackendSrv);
+
+//
+// Code below is to expore the service to react components
+//
+
+let singletonInstance: BackendSrv;
+
+export function setBackendSrv(instance: BackendSrv) {
+  singletonInstance = instance;
+}
+
+export function getBackendSrv(): BackendSrv {
+  return singletonInstance;
+}

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

@@ -957,7 +957,7 @@ kbn.getUnitFormats = function() {
       text: 'throughput',
       submenu: [
         { text: 'ops/sec (ops)', value: 'ops' },
-        { text: 'requets/sec (rps)', value: 'reqps' },
+        { text: 'requests/sec (rps)', value: 'reqps' },
         { text: 'reads/sec (rps)', value: 'rps' },
         { text: 'writes/sec (wps)', value: 'wps' },
         { text: 'I/O ops/sec (iops)', value: 'iops' },

+ 1 - 1
public/app/features/dashboard/dashnav/dashnav.html

@@ -3,7 +3,7 @@
 	<div>
 		<a class="navbar-page-btn" ng-click="ctrl.showSearch()">
 			<i class="gicon gicon-dashboard"></i>
-			{{ctrl.dashboard.title}}
+			<span ng-if="ctrl.dashboard.meta.folderId > 0" class="navbar-page-btn--folder">{{ctrl.dashboard.meta.folderTitle}} / </span>{{ctrl.dashboard.title}}
 			<i class="fa fa-caret-down"></i>
 		</a>
 	</div>

+ 7 - 14
public/app/features/dashboard/folder_picker/folder_picker.ts

@@ -104,10 +104,7 @@ export class FolderPickerCtrl {
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
 
       this.closeCreateFolder();
-      this.folder = {
-        text: result.title,
-        value: result.id,
-      };
+      this.folder = { text: result.title, value: result.id };
       this.onFolderChange(this.folder);
     });
   }
@@ -149,17 +146,14 @@ export class FolderPickerCtrl {
           folder = result.length > 0 ? result[0] : resetFolder;
         }
       }
+
       this.folder = folder;
-      this.onFolderLoad();
-    });
-  }
 
-  private onFolderLoad() {
-    if (this.onLoad) {
-      this.onLoad({
-        $folder: { id: this.folder.value, title: this.folder.text },
-      });
-    }
+      // if this is not the same as our initial value notify parent
+      if (this.folder.id !== this.initialFolderId) {
+        this.onChange({ $folder: { id: this.folder.value, title: this.folder.text } });
+      }
+    });
   }
 }
 
@@ -176,7 +170,6 @@ export function folderPicker() {
       labelClass: '@',
       rootName: '@',
       onChange: '&',
-      onLoad: '&',
       onCreateFolder: '&',
       enterFolderCreation: '&',
       exitFolderCreation: '&',

+ 0 - 2
public/app/features/org/all.ts

@@ -5,8 +5,6 @@ import './select_org_ctrl';
 import './change_password_ctrl';
 import './new_org_ctrl';
 import './user_invite_ctrl';
-import './teams_ctrl';
-import './team_details_ctrl';
 import './create_team_ctrl';
 import './org_api_keys_ctrl';
 import './org_details_ctrl';

+ 0 - 105
public/app/features/org/partials/team_details.html

@@ -1,105 +0,0 @@
-<page-header model="ctrl.navModel"></page-header>
-
-<div class="page-container page-body">
-  <h3 class="page-sub-heading">Team Details</h3>
-
-  <form name="teamDetailsForm" class="gf-form-group">
-    <div class="gf-form max-width-30">
-      <span class="gf-form-label width-10">Name</span>
-      <input type="text" required ng-model="ctrl.team.name" class="gf-form-input max-width-22">
-    </div>
-    <div class="gf-form max-width-30">
-      <span class="gf-form-label width-10">
-        Email
-        <info-popover mode="right-normal">
-          This is optional and is primarily used for allowing custom team avatars.
-        </info-popover>
-      </span>
-      <input class="gf-form-input max-width-22" type="email" ng-model="ctrl.team.email" placeholder="email@test.com">
-    </div>
-
-    <div class="gf-form-button-row">
-      <button type="submit" class="btn btn-success" ng-click="ctrl.update()">Update</button>
-    </div>
-  </form>
-
-  <div class="gf-form-group">
-
-    <h3 class="page-heading">Team Members</h3>
-    <form name="ctrl.addMemberForm" class="gf-form-group">
-      <div class="gf-form">
-        <span class="gf-form-label width-10">Add member</span>
-        <!--
-        Old picker
-        <user-picker user-picked="ctrl.userPicked($user)"></user-picker>
-        -->
-        <select-user-picker class="width-7" handlePicked="ctrl.userPicked" backendSrv="ctrl.backendSrv"></select-user-picker>
-      </div>
-    </form>
-
-    <table class="filter-table" ng-show="ctrl.teamMembers.length > 0">
-      <thead>
-        <tr>
-          <th></th>
-          <th>Username</th>
-          <th>Email</th>
-          <th></th>
-        </tr>
-      </thead>
-      <tr ng-repeat="member in ctrl.teamMembers">
-        <td class="width-4 text-center link-td">
-          <img class="filter-table__avatar" ng-src="{{member.avatarUrl}}"></img>
-        </td>
-        <td>{{member.login}}</td>
-        <td>{{member.email}}</td>
-        <td style="width: 1%">
-          <a ng-click="ctrl.removeTeamMember(member)" class="btn btn-danger btn-mini">
-            <i class="fa fa-remove"></i>
-          </a>
-        </td>
-      </tr>
-    </table>
-    <div>
-      <em class="muted" ng-hide="ctrl.teamMembers.length > 0">
-        This team has no members yet.
-      </em>
-    </div>
-
-  </div>
-
-  <div class="gf-form-group" ng-if="ctrl.isMappingsEnabled">
-
-	<h3 class="page-heading">Mappings to external groups</h3>
-	<form name="ctrl.addGroupForm" class="gf-form-group">
-		<div class="gf-form">
-			<span class="gf-form-label width-10">Add group</span>
-			<input class="gf-form-input max-width-22" type="text" ng-model="ctrl.newGroupId">
-		</div>
-		<div class="gf-form-button-row">
-			<button type="submit" class="btn btn-success" ng-click="ctrl.addGroup()">Add</button>
-		</div>
-	</form>
-
-	<table class="filter-table" ng-show="ctrl.teamGroups.length > 0">
-		<thead>
-			<tr>
-				<th>Group</th>
-				<th></th>
-			</tr>
-		</thead>
-		<tr ng-repeat="group in ctrl.teamGroups">
-			<td>{{group.groupId}}</td>
-			<td style="width: 1%">
-				<a ng-click="ctrl.removeGroup(group)" class="btn btn-danger btn-mini">
-					<i class="fa fa-remove"></i>
-				</a>
-			</td>
-		</tr>
-	</table>
-	<div>
-		<em class="muted" ng-hide="ctrl.teamGroups.length > 0">
-			This team has no associated groups yet.
-		</em>
-	</div>
-
-	</div>

+ 0 - 68
public/app/features/org/partials/teams.html

@@ -1,68 +0,0 @@
-<page-header model="ctrl.navModel"></page-header>
-
-<div class="page-container page-body">
-	<div class="page-action-bar">
-    <label class="gf-form gf-form--grow gf-form--has-input-icon">
-      <input type="text" class="gf-form-input max-width-20" placeholder="Find Team by name" tabindex="1" ng-model="ctrl.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.get()" />
-      <i class="gf-form-input-icon fa fa-search"></i>
-    </label>
-    <div class="page-action-bar__spacer"></div>
-
-    <a class="btn btn-success" href="org/teams/new">
-      <i class="fa fa-plus"></i>
-			Add Team
-    </a>
-  </div>
-
-  <div class="admin-list-table">
-    <table class="filter-table filter-table--hover form-inline" ng-show="ctrl.teams.length > 0">
-      <thead>
-        <tr>
-          <th></th>
-          <th>Name</th>
-          <th>Email</th>
-          <th>Members</th>
-          <th style="width: 1%"></th>
-        </tr>
-      </thead>
-      <tbody>
-        <tr ng-repeat="team in ctrl.teams">
-          <td class="width-4 text-center link-td">
-            <a href="org/teams/edit/{{team.id}}">
-              <img class="filter-table__avatar" ng-src="{{team.avatarUrl}}"></img>
-            </a>
-          </td>
-          <td class="link-td">
-            <a href="org/teams/edit/{{team.id}}">{{team.name}}</a>
-          </td>
-          <td class="link-td">
-              <a href="org/teams/edit/{{team.id}}">{{team.email}}</a>
-            </td>
-          <td class="link-td">
-            <a href="org/teams/edit/{{team.id}}">{{team.memberCount}}</a>
-          </td>
-          <td class="text-right">
-            <a ng-click="ctrl.deleteTeam(team)" class="btn btn-danger btn-small">
-              <i class="fa fa-remove"></i>
-            </a>
-          </td>
-        </tr>
-      </tbody>
-    </table>
-  </div>
-
-  <div class="admin-list-paging" ng-if="ctrl.showPaging">
-    <ol>
-      <li ng-repeat="page in ctrl.pages">
-        <button
-          class="btn btn-small"
-          ng-class="{'btn-secondary': page.current, 'btn-inverse': !page.current}"
-          ng-click="ctrl.navigateToPage(page)">{{page.page}}</button>
-      </li>
-    </ol>
-  </div>
-
-  <em class="muted" ng-hide="ctrl.teams.length > 0">
-    No Teams found.
-  </em>
-</div>

+ 0 - 42
public/app/features/org/specs/team_details_ctrl.jest.ts

@@ -1,42 +0,0 @@
-import '../team_details_ctrl';
-import TeamDetailsCtrl from '../team_details_ctrl';
-
-describe('TeamDetailsCtrl', () => {
-  var backendSrv = {
-    searchUsers: jest.fn(() => Promise.resolve([])),
-    get: jest.fn(() => Promise.resolve([])),
-    post: jest.fn(() => Promise.resolve([])),
-  };
-
-  //Team id
-  var routeParams = {
-    id: 1,
-  };
-
-  var navModelSrv = {
-    getNav: jest.fn(),
-  };
-
-  var teamDetailsCtrl = new TeamDetailsCtrl({ $broadcast: jest.fn() }, backendSrv, routeParams, navModelSrv);
-
-  describe('when user is chosen to be added to team', () => {
-    beforeEach(() => {
-      teamDetailsCtrl = new TeamDetailsCtrl({ $broadcast: jest.fn() }, backendSrv, routeParams, navModelSrv);
-      const userItem = {
-        id: 2,
-        login: 'user2',
-      };
-      teamDetailsCtrl.userPicked(userItem);
-    });
-
-    it('should parse the result and save to db', () => {
-      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/teams/1/members');
-      expect(backendSrv.post.mock.calls[0][1].userId).toBe(2);
-    });
-
-    it('should refresh the list after saving.', () => {
-      expect(backendSrv.get.mock.calls[0][0]).toBe('/api/teams/1');
-      expect(backendSrv.get.mock.calls[1][0]).toBe('/api/teams/1/members');
-    });
-  });
-});

+ 0 - 108
public/app/features/org/team_details_ctrl.ts

@@ -1,108 +0,0 @@
-import coreModule from 'app/core/core_module';
-import config from 'app/core/config';
-
-export default class TeamDetailsCtrl {
-  team: Team;
-  teamMembers: User[] = [];
-  navModel: any;
-  teamGroups: TeamGroup[] = [];
-  newGroupId: string;
-  isMappingsEnabled: boolean;
-
-  /** @ngInject **/
-  constructor(private $scope, private backendSrv, private $routeParams, navModelSrv) {
-    this.navModel = navModelSrv.getNav('cfg', 'teams', 0);
-    this.userPicked = this.userPicked.bind(this);
-    this.get = this.get.bind(this);
-    this.newGroupId = '';
-    this.isMappingsEnabled = config.buildInfo.isEnterprise;
-    this.get();
-  }
-
-  get() {
-    if (this.$routeParams && this.$routeParams.id) {
-      this.backendSrv.get(`/api/teams/${this.$routeParams.id}`).then(result => {
-        this.team = result;
-      });
-
-      this.backendSrv.get(`/api/teams/${this.$routeParams.id}/members`).then(result => {
-        this.teamMembers = result;
-      });
-
-      if (this.isMappingsEnabled) {
-        this.backendSrv.get(`/api/teams/${this.$routeParams.id}/groups`).then(result => {
-          this.teamGroups = result;
-        });
-      }
-    }
-  }
-
-  removeTeamMember(teamMember: TeamMember) {
-    this.$scope.appEvent('confirm-modal', {
-      title: 'Remove Member',
-      text: 'Are you sure you want to remove ' + teamMember.login + ' from this group?',
-      yesText: 'Remove',
-      icon: 'fa-warning',
-      onConfirm: () => {
-        this.removeMemberConfirmed(teamMember);
-      },
-    });
-  }
-
-  removeMemberConfirmed(teamMember: TeamMember) {
-    this.backendSrv.delete(`/api/teams/${this.$routeParams.id}/members/${teamMember.userId}`).then(this.get);
-  }
-
-  update() {
-    if (!this.$scope.teamDetailsForm.$valid) {
-      return;
-    }
-
-    this.backendSrv.put('/api/teams/' + this.team.id, {
-      name: this.team.name,
-      email: this.team.email,
-    });
-  }
-
-  userPicked(user) {
-    this.backendSrv.post(`/api/teams/${this.$routeParams.id}/members`, { userId: user.id }).then(() => {
-      this.$scope.$broadcast('user-picker-reset');
-      this.get();
-    });
-  }
-
-  addGroup() {
-    this.backendSrv.post(`/api/teams/${this.$routeParams.id}/groups`, { groupId: this.newGroupId }).then(() => {
-      this.get();
-    });
-  }
-
-  removeGroup(group: TeamGroup) {
-    this.backendSrv.delete(`/api/teams/${this.$routeParams.id}/groups/${group.groupId}`).then(this.get);
-  }
-}
-
-export interface TeamGroup {
-  groupId: string;
-}
-
-export interface Team {
-  id: number;
-  name: string;
-  email: string;
-}
-
-export interface User {
-  id: number;
-  name: string;
-  login: string;
-  email: string;
-}
-
-export interface TeamMember {
-  userId: number;
-  name: string;
-  login: string;
-}
-
-coreModule.controller('TeamDetailsCtrl', TeamDetailsCtrl);

+ 0 - 66
public/app/features/org/teams_ctrl.ts

@@ -1,66 +0,0 @@
-import coreModule from 'app/core/core_module';
-import appEvents from 'app/core/app_events';
-
-export class TeamsCtrl {
-  teams: any;
-  pages = [];
-  perPage = 50;
-  page = 1;
-  totalPages: number;
-  showPaging = false;
-  query: any = '';
-  navModel: any;
-
-  /** @ngInject */
-  constructor(private backendSrv, navModelSrv) {
-    this.navModel = navModelSrv.getNav('cfg', 'teams', 0);
-    this.get();
-  }
-
-  get() {
-    this.backendSrv
-      .get(`/api/teams/search?perpage=${this.perPage}&page=${this.page}&query=${this.query}`)
-      .then(result => {
-        this.teams = result.teams;
-        this.page = result.page;
-        this.perPage = result.perPage;
-        this.totalPages = Math.ceil(result.totalCount / result.perPage);
-        this.showPaging = this.totalPages > 1;
-        this.pages = [];
-
-        for (var i = 1; i < this.totalPages + 1; i++) {
-          this.pages.push({ page: i, current: i === this.page });
-        }
-      });
-  }
-
-  navigateToPage(page) {
-    this.page = page.page;
-    this.get();
-  }
-
-  deleteTeam(team) {
-    appEvents.emit('confirm-modal', {
-      title: 'Delete',
-      text: 'Are you sure you want to delete Team ' + team.name + '?',
-      yesText: 'Delete',
-      icon: 'fa-warning',
-      onConfirm: () => {
-        this.deleteTeamConfirmed(team);
-      },
-    });
-  }
-
-  deleteTeamConfirmed(team) {
-    this.backendSrv.delete('/api/teams/' + team.id).then(this.get.bind(this));
-  }
-
-  openTeamModal() {
-    appEvents.emit('show-modal', {
-      templateHtml: '<create-team-modal></create-team-modal>',
-      modalClass: 'modal--narrow',
-    });
-  }
-}
-
-coreModule.controller('TeamsCtrl', TeamsCtrl);

+ 1 - 1
public/app/features/panel/metrics_panel_ctrl.ts

@@ -218,7 +218,7 @@ class MetricsPanelCtrl extends PanelCtrl {
     // and add built in variables interval and interval_ms
     var scopedVars = Object.assign({}, this.panel.scopedVars, {
       __interval: { text: this.interval, value: this.interval },
-      __interval_ms: { text: this.intervalMs, value: this.intervalMs },
+      __interval_ms: { text: String(this.intervalMs), value: String(this.intervalMs) },
     });
 
     var metricsQuery = {

+ 1 - 1
public/app/features/plugins/plugin_loader.ts

@@ -57,7 +57,7 @@ System.config({
     css: 'vendor/plugin-css/css.js',
   },
   meta: {
-    'plugin*': {
+    '*': {
       esModule: true,
       authorization: true,
       loader: 'plugin-loader',

+ 2 - 0
public/app/features/templating/adhoc_variable.ts

@@ -3,6 +3,7 @@ import { Variable, assignModelProperties, variableTypes } from './variable';
 
 export class AdhocVariable implements Variable {
   filters: any[];
+  skipUrlSync: boolean;
 
   defaults = {
     type: 'adhoc',
@@ -11,6 +12,7 @@ export class AdhocVariable implements Variable {
     hide: 0,
     datasource: null,
     filters: [],
+    skipUrlSync: false,
   };
 
   /** @ngInject **/

+ 2 - 0
public/app/features/templating/constant_variable.ts

@@ -4,6 +4,7 @@ export class ConstantVariable implements Variable {
   query: string;
   options: any[];
   current: any;
+  skipUrlSync: boolean;
 
   defaults = {
     type: 'constant',
@@ -13,6 +14,7 @@ export class ConstantVariable implements Variable {
     query: '',
     current: {},
     options: [],
+    skipUrlSync: false,
   };
 
   /** @ngInject **/

+ 2 - 0
public/app/features/templating/custom_variable.ts

@@ -7,6 +7,7 @@ export class CustomVariable implements Variable {
   includeAll: boolean;
   multi: boolean;
   current: any;
+  skipUrlSync: boolean;
 
   defaults = {
     type: 'custom',
@@ -19,6 +20,7 @@ export class CustomVariable implements Variable {
     includeAll: false,
     multi: false,
     allValue: null,
+    skipUrlSync: false,
   };
 
   /** @ngInject **/

+ 2 - 0
public/app/features/templating/datasource_variable.ts

@@ -7,6 +7,7 @@ export class DatasourceVariable implements Variable {
   options: any;
   current: any;
   refresh: any;
+  skipUrlSync: boolean;
 
   defaults = {
     type: 'datasource',
@@ -18,6 +19,7 @@ export class DatasourceVariable implements Variable {
     options: [],
     query: '',
     refresh: 1,
+    skipUrlSync: false,
   };
 
   /** @ngInject **/

+ 2 - 0
public/app/features/templating/interval_variable.ts

@@ -11,6 +11,7 @@ export class IntervalVariable implements Variable {
   query: string;
   refresh: number;
   current: any;
+  skipUrlSync: boolean;
 
   defaults = {
     type: 'interval',
@@ -24,6 +25,7 @@ export class IntervalVariable implements Variable {
     auto: false,
     auto_min: '10s',
     auto_count: 30,
+    skipUrlSync: false,
   };
 
   /** @ngInject **/

+ 2 - 0
public/app/features/templating/query_variable.ts

@@ -22,6 +22,7 @@ export class QueryVariable implements Variable {
   tagsQuery: string;
   tagValuesQuery: string;
   tags: any[];
+  skipUrlSync: boolean;
 
   defaults = {
     type: 'query',
@@ -42,6 +43,7 @@ export class QueryVariable implements Variable {
     useTags: false,
     tagsQuery: '',
     tagValuesQuery: '',
+    skipUrlSync: false,
   };
 
   /** @ngInject **/

+ 57 - 0
public/app/features/templating/specs/template_srv.jest.ts

@@ -345,6 +345,49 @@ describe('templateSrv', function() {
     });
   });
 
+  describe('fillVariableValuesForUrl skip url sync', function() {
+    beforeEach(function() {
+      initTemplateSrv([
+        {
+          name: 'test',
+          skipUrlSync: true,
+          current: { value: 'value' },
+          getValueForUrl: function() {
+            return this.current.value;
+          },
+        },
+      ]);
+    });
+
+    it('should not include template variable value in url', function() {
+      var params = {};
+      _templateSrv.fillVariableValuesForUrl(params);
+      expect(params['var-test']).toBe(undefined);
+    });
+  });
+
+  describe('fillVariableValuesForUrl with multi value with skip url sync', function() {
+    beforeEach(function() {
+      initTemplateSrv([
+        {
+          type: 'query',
+          name: 'test',
+          skipUrlSync: true,
+          current: { value: ['val1', 'val2'] },
+          getValueForUrl: function() {
+            return this.current.value;
+          },
+        },
+      ]);
+    });
+
+    it('should not include template variable value in url', function() {
+      var params = {};
+      _templateSrv.fillVariableValuesForUrl(params);
+      expect(params['var-test']).toBe(undefined);
+    });
+  });
+
   describe('fillVariableValuesForUrl with multi value and scopedVars', function() {
     beforeEach(function() {
       initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }]);
@@ -359,6 +402,20 @@ describe('templateSrv', function() {
     });
   });
 
+  describe('fillVariableValuesForUrl with multi value, scopedVars and skip url sync', function() {
+    beforeEach(function() {
+      initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }]);
+    });
+
+    it('should not set scoped value as url params', function() {
+      var params = {};
+      _templateSrv.fillVariableValuesForUrl(params, {
+        test: { name: 'test', value: 'val1', skipUrlSync: true },
+      });
+      expect(params['var-test']).toBe(undefined);
+    });
+  });
+
   describe('replaceWithText', function() {
     beforeEach(function() {
       initTemplateSrv([

+ 6 - 0
public/app/features/templating/template_srv.ts

@@ -250,8 +250,14 @@ export class TemplateSrv {
   fillVariableValuesForUrl(params, scopedVars) {
     _.each(this.variables, function(variable) {
       if (scopedVars && scopedVars[variable.name] !== void 0) {
+        if (scopedVars[variable.name].skipUrlSync) {
+          return;
+        }
         params['var-' + variable.name] = scopedVars[variable.name].value;
       } else {
+        if (variable.skipUrlSync) {
+          return;
+        }
         params['var-' + variable.name] = variable.getValueForUrl();
       }
     });

+ 9 - 0
public/app/plugins/datasource/cloudwatch/datasource.ts

@@ -39,6 +39,14 @@ export default class CloudWatchDatasource {
       item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars);
       item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting
 
+      // valid ExtendedStatistics is like p90.00, check the pattern
+      let hasInvalidStatistics = item.statistics.some(s => {
+        return s.indexOf('p') === 0 && !/p\d{2}\.\d{2}/.test(s);
+      });
+      if (hasInvalidStatistics) {
+        throw { message: 'Invalid extended statistics' };
+      }
+
       return _.extend(
         {
           refId: item.refId,
@@ -404,6 +412,7 @@ export default class CloudWatchDatasource {
   }
 
   expandTemplateVariable(targets, scopedVars, templateSrv) {
+    // Datasource and template srv logic uber-complected. This should be cleaned up.
     return _.chain(targets)
       .map(target => {
         var dimensionKey = _.findKey(target.dimensions, v => {

+ 92 - 169
public/app/plugins/datasource/cloudwatch/specs/datasource_specs.ts → public/app/plugins/datasource/cloudwatch/specs/datasource.jest.ts

@@ -1,32 +1,38 @@
 import '../datasource';
-import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
-import helpers from 'test/specs/helpers';
 import CloudWatchDatasource from '../datasource';
-import 'app/features/dashboard/time_srv';
+import * as dateMath from 'app/core/utils/datemath';
+import _ from 'lodash';
 
 describe('CloudWatchDatasource', function() {
-  var ctx = new helpers.ServiceTestContext();
-  var instanceSettings = {
+  let instanceSettings = {
     jsonData: { defaultRegion: 'us-east-1', access: 'proxy' },
   };
 
-  beforeEach(angularMocks.module('grafana.core'));
-  beforeEach(angularMocks.module('grafana.services'));
-  beforeEach(angularMocks.module('grafana.controllers'));
-  beforeEach(ctx.providePhase(['templateSrv', 'backendSrv']));
-  beforeEach(ctx.createService('timeSrv'));
-
-  beforeEach(
-    angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) {
-      ctx.$q = $q;
-      ctx.$httpBackend = $httpBackend;
-      ctx.$rootScope = $rootScope;
-      ctx.ds = $injector.instantiate(CloudWatchDatasource, {
-        instanceSettings: instanceSettings,
-      });
-      $httpBackend.when('GET', /\.html$/).respond('');
-    })
-  );
+  let templateSrv = {
+    data: {},
+    templateSettings: { interpolate: /\[\[([\s\S]+?)\]\]/g },
+    replace: text => _.template(text, templateSrv.templateSettings)(templateSrv.data),
+    variableExists: () => false,
+  };
+
+  let timeSrv = {
+    time: { from: 'now-1h', to: 'now' },
+    timeRange: () => {
+      return {
+        from: dateMath.parse(timeSrv.time.from, false),
+        to: dateMath.parse(timeSrv.time.to, true),
+      };
+    },
+  };
+  let backendSrv = {};
+  let ctx = <any>{
+    backendSrv,
+    templateSrv,
+  };
+
+  beforeEach(() => {
+    ctx.ds = new CloudWatchDatasource(instanceSettings, {}, backendSrv, templateSrv, timeSrv);
+  });
 
   describe('When performing CloudWatch query', function() {
     var requestParams;
@@ -67,24 +73,23 @@ describe('CloudWatchDatasource', function() {
       },
     };
 
-    beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(params) {
+    beforeEach(() => {
+      ctx.backendSrv.datasourceRequest = jest.fn(params => {
         requestParams = params.data;
-        return ctx.$q.when({ data: response });
-      };
+        return Promise.resolve({ data: response });
+      });
     });
 
     it('should generate the correct query', function(done) {
       ctx.ds.query(query).then(function() {
         var params = requestParams.queries[0];
-        expect(params.namespace).to.be(query.targets[0].namespace);
-        expect(params.metricName).to.be(query.targets[0].metricName);
-        expect(params.dimensions['InstanceId']).to.be('i-12345678');
-        expect(params.statistics).to.eql(query.targets[0].statistics);
-        expect(params.period).to.be(query.targets[0].period);
+        expect(params.namespace).toBe(query.targets[0].namespace);
+        expect(params.metricName).toBe(query.targets[0].metricName);
+        expect(params.dimensions['InstanceId']).toBe('i-12345678');
+        expect(params.statistics).toEqual(query.targets[0].statistics);
+        expect(params.period).toBe(query.targets[0].period);
         done();
       });
-      ctx.$rootScope.$apply();
     });
 
     it('should generate the correct query with interval variable', function(done) {
@@ -111,116 +116,37 @@ describe('CloudWatchDatasource', function() {
 
       ctx.ds.query(query).then(function() {
         var params = requestParams.queries[0];
-        expect(params.period).to.be('600');
+        expect(params.period).toBe('600');
         done();
       });
-      ctx.$rootScope.$apply();
     });
 
-    it('should return series list', function(done) {
-      ctx.ds.query(query).then(function(result) {
-        expect(result.data[0].target).to.be(response.results.A.series[0].name);
-        expect(result.data[0].datapoints[0][0]).to.be(response.results.A.series[0].points[0][0]);
-        done();
-      });
-      ctx.$rootScope.$apply();
-    });
-
-    it('should generate the correct targets by expanding template variables', function() {
-      var templateSrv = {
-        variables: [
+    it('should cancel query for invalid extended statistics', function () {
+      var query = {
+        range: { from: 'now-1h', to: 'now' },
+        rangeRaw: { from: 1483228800, to: 1483232400 },
+        targets: [
           {
-            name: 'instance_id',
-            options: [
-              { text: 'i-23456789', value: 'i-23456789', selected: false },
-              { text: 'i-34567890', value: 'i-34567890', selected: true },
-            ],
-            current: {
-              text: 'i-34567890',
-              value: 'i-34567890',
+            region: 'us-east-1',
+            namespace: 'AWS/EC2',
+            metricName: 'CPUUtilization',
+            dimensions: {
+              InstanceId: 'i-12345678',
             },
+            statistics: ['pNN.NN'],
+            period: '60s',
           },
         ],
-        replace: function(target, scopedVars) {
-          if (target === '$instance_id' && scopedVars['instance_id']['text'] === 'i-34567890') {
-            return 'i-34567890';
-          } else {
-            return '';
-          }
-        },
-        getVariableName: function(e) {
-          return 'instance_id';
-        },
-        variableExists: function(e) {
-          return true;
-        },
-        containsVariable: function(str, variableName) {
-          return str.indexOf('$' + variableName) !== -1;
-        },
       };
-
-      var targets = [
-        {
-          region: 'us-east-1',
-          namespace: 'AWS/EC2',
-          metricName: 'CPUUtilization',
-          dimensions: {
-            InstanceId: '$instance_id',
-          },
-          statistics: ['Average'],
-          period: 300,
-        },
-      ];
-
-      var result = ctx.ds.expandTemplateVariable(targets, {}, templateSrv);
-      expect(result[0].dimensions.InstanceId).to.be('i-34567890');
+      expect(ctx.ds.query.bind(ctx.ds, query)).toThrow(/Invalid extended statistics/);
     });
 
-    it('should generate the correct targets by expanding template variables from url', function() {
-      var templateSrv = {
-        variables: [
-          {
-            name: 'instance_id',
-            options: [
-              { text: 'i-23456789', value: 'i-23456789', selected: false },
-              { text: 'i-34567890', value: 'i-34567890', selected: false },
-            ],
-            current: 'i-45678901',
-          },
-        ],
-        replace: function(target, scopedVars) {
-          if (target === '$instance_id') {
-            return 'i-45678901';
-          } else {
-            return '';
-          }
-        },
-        getVariableName: function(e) {
-          return 'instance_id';
-        },
-        variableExists: function(e) {
-          return true;
-        },
-        containsVariable: function(str, variableName) {
-          return str.indexOf('$' + variableName) !== -1;
-        },
-      };
-
-      var targets = [
-        {
-          region: 'us-east-1',
-          namespace: 'AWS/EC2',
-          metricName: 'CPUUtilization',
-          dimensions: {
-            InstanceId: '$instance_id',
-          },
-          statistics: ['Average'],
-          period: 300,
-        },
-      ];
-
-      var result = ctx.ds.expandTemplateVariable(targets, {}, templateSrv);
-      expect(result[0].dimensions.InstanceId).to.be('i-45678901');
+    it('should return series list', function(done) {
+      ctx.ds.query(query).then(function(result) {
+        expect(result.data[0].target).toBe(response.results.A.series[0].name);
+        expect(result.data[0].datapoints[0][0]).toBe(response.results.A.series[0].points[0][0]);
+        done();
+      });
     });
   });
 
@@ -228,21 +154,21 @@ describe('CloudWatchDatasource', function() {
     it('should return the datasource region if empty or "default"', function() {
       var defaultRegion = instanceSettings.jsonData.defaultRegion;
 
-      expect(ctx.ds.getActualRegion()).to.be(defaultRegion);
-      expect(ctx.ds.getActualRegion('')).to.be(defaultRegion);
-      expect(ctx.ds.getActualRegion('default')).to.be(defaultRegion);
+      expect(ctx.ds.getActualRegion()).toBe(defaultRegion);
+      expect(ctx.ds.getActualRegion('')).toBe(defaultRegion);
+      expect(ctx.ds.getActualRegion('default')).toBe(defaultRegion);
     });
 
     it('should return the specified region if specified', function() {
-      expect(ctx.ds.getActualRegion('some-fake-region-1')).to.be('some-fake-region-1');
+      expect(ctx.ds.getActualRegion('some-fake-region-1')).toBe('some-fake-region-1');
     });
 
     var requestParams;
     beforeEach(function() {
-      ctx.ds.performTimeSeriesQuery = function(request) {
+      ctx.ds.performTimeSeriesQuery = jest.fn(request => {
         requestParams = request;
-        return ctx.$q.when({ data: {} });
-      };
+        return Promise.resolve({ data: {} });
+      });
     });
 
     it('should query for the datasource region if empty or "default"', function(done) {
@@ -264,10 +190,9 @@ describe('CloudWatchDatasource', function() {
       };
 
       ctx.ds.query(query).then(function(result) {
-        expect(requestParams.queries[0].region).to.be(instanceSettings.jsonData.defaultRegion);
+        expect(requestParams.queries[0].region).toBe(instanceSettings.jsonData.defaultRegion);
         done();
       });
-      ctx.$rootScope.$apply();
     });
   });
 
@@ -311,18 +236,17 @@ describe('CloudWatchDatasource', function() {
     };
 
     beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(params) {
-        return ctx.$q.when({ data: response });
-      };
+      ctx.backendSrv.datasourceRequest = jest.fn(params => {
+        return Promise.resolve({ data: response });
+      });
     });
 
     it('should return series list', function(done) {
       ctx.ds.query(query).then(function(result) {
-        expect(result.data[0].target).to.be(response.results.A.series[0].name);
-        expect(result.data[0].datapoints[0][0]).to.be(response.results.A.series[0].points[0][0]);
+        expect(result.data[0].target).toBe(response.results.A.series[0].name);
+        expect(result.data[0].datapoints[0][0]).toBe(response.results.A.series[0].points[0][0]);
         done();
       });
-      ctx.$rootScope.$apply();
     });
   });
 
@@ -332,14 +256,13 @@ describe('CloudWatchDatasource', function() {
       scenario.setup = setupCallback => {
         beforeEach(() => {
           setupCallback();
-          ctx.backendSrv.datasourceRequest = args => {
+          ctx.backendSrv.datasourceRequest = jest.fn(args => {
             scenario.request = args.data;
-            return ctx.$q.when({ data: scenario.requestResponse });
-          };
+            return Promise.resolve({ data: scenario.requestResponse });
+          });
           ctx.ds.metricFindQuery(query).then(args => {
             scenario.result = args;
           });
-          ctx.$rootScope.$apply();
         });
       };
 
@@ -359,9 +282,9 @@ describe('CloudWatchDatasource', function() {
     });
 
     it('should call __GetRegions and return result', () => {
-      expect(scenario.result[0].text).to.contain('us-east-1');
-      expect(scenario.request.queries[0].type).to.be('metricFindQuery');
-      expect(scenario.request.queries[0].subtype).to.be('regions');
+      expect(scenario.result[0].text).toContain('us-east-1');
+      expect(scenario.request.queries[0].type).toBe('metricFindQuery');
+      expect(scenario.request.queries[0].subtype).toBe('regions');
     });
   });
 
@@ -377,9 +300,9 @@ describe('CloudWatchDatasource', function() {
     });
 
     it('should call __GetNamespaces and return result', () => {
-      expect(scenario.result[0].text).to.contain('AWS/EC2');
-      expect(scenario.request.queries[0].type).to.be('metricFindQuery');
-      expect(scenario.request.queries[0].subtype).to.be('namespaces');
+      expect(scenario.result[0].text).toContain('AWS/EC2');
+      expect(scenario.request.queries[0].type).toBe('metricFindQuery');
+      expect(scenario.request.queries[0].subtype).toBe('namespaces');
     });
   });
 
@@ -395,9 +318,9 @@ describe('CloudWatchDatasource', function() {
     });
 
     it('should call __GetMetrics and return result', () => {
-      expect(scenario.result[0].text).to.be('CPUUtilization');
-      expect(scenario.request.queries[0].type).to.be('metricFindQuery');
-      expect(scenario.request.queries[0].subtype).to.be('metrics');
+      expect(scenario.result[0].text).toBe('CPUUtilization');
+      expect(scenario.request.queries[0].type).toBe('metricFindQuery');
+      expect(scenario.request.queries[0].subtype).toBe('metrics');
     });
   });
 
@@ -413,9 +336,9 @@ describe('CloudWatchDatasource', function() {
     });
 
     it('should call __GetDimensions and return result', () => {
-      expect(scenario.result[0].text).to.be('InstanceId');
-      expect(scenario.request.queries[0].type).to.be('metricFindQuery');
-      expect(scenario.request.queries[0].subtype).to.be('dimension_keys');
+      expect(scenario.result[0].text).toBe('InstanceId');
+      expect(scenario.request.queries[0].type).toBe('metricFindQuery');
+      expect(scenario.request.queries[0].subtype).toBe('dimension_keys');
     });
   });
 
@@ -431,9 +354,9 @@ describe('CloudWatchDatasource', function() {
     });
 
     it('should call __ListMetrics and return result', () => {
-      expect(scenario.result[0].text).to.contain('i-12345678');
-      expect(scenario.request.queries[0].type).to.be('metricFindQuery');
-      expect(scenario.request.queries[0].subtype).to.be('dimension_values');
+      expect(scenario.result[0].text).toContain('i-12345678');
+      expect(scenario.request.queries[0].type).toBe('metricFindQuery');
+      expect(scenario.request.queries[0].subtype).toBe('dimension_values');
     });
   });
 
@@ -449,9 +372,9 @@ describe('CloudWatchDatasource', function() {
     });
 
     it('should call __ListMetrics and return result', () => {
-      expect(scenario.result[0].text).to.contain('i-12345678');
-      expect(scenario.request.queries[0].type).to.be('metricFindQuery');
-      expect(scenario.request.queries[0].subtype).to.be('dimension_values');
+      expect(scenario.result[0].text).toContain('i-12345678');
+      expect(scenario.request.queries[0].type).toBe('metricFindQuery');
+      expect(scenario.request.queries[0].subtype).toBe('dimension_values');
     });
   });
 
@@ -544,7 +467,7 @@ describe('CloudWatchDatasource', function() {
       let now = new Date(options.range.from.valueOf() + t[2] * 1000);
       let expected = t[3];
       let actual = ctx.ds.getPeriod(target, options, now);
-      expect(actual).to.be(expected);
+      expect(actual).toBe(expected);
     }
   });
 });

+ 48 - 59
public/app/plugins/datasource/mysql/specs/datasource_specs.ts → public/app/plugins/datasource/mysql/specs/datasource.jest.ts

@@ -1,28 +1,21 @@
-import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
 import moment from 'moment';
-import helpers from 'test/specs/helpers';
 import { MysqlDatasource } from '../datasource';
 import { CustomVariable } from 'app/features/templating/custom_variable';
 
 describe('MySQLDatasource', function() {
-  var ctx = new helpers.ServiceTestContext();
-  var instanceSettings = { name: 'mysql' };
-
-  beforeEach(angularMocks.module('grafana.core'));
-  beforeEach(angularMocks.module('grafana.services'));
-  beforeEach(ctx.providePhase(['backendSrv']));
-
-  beforeEach(
-    angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) {
-      ctx.$q = $q;
-      ctx.$httpBackend = $httpBackend;
-      ctx.$rootScope = $rootScope;
-      ctx.ds = $injector.instantiate(MysqlDatasource, {
-        instanceSettings: instanceSettings,
-      });
-      $httpBackend.when('GET', /\.html$/).respond('');
-    })
-  );
+  let instanceSettings = { name: 'mysql' };
+  let backendSrv = {};
+  let templateSrv = {
+    replace: jest.fn(text => text),
+  };
+
+  let ctx = <any>{
+    backendSrv,
+  };
+
+  beforeEach(() => {
+    ctx.ds = new MysqlDatasource(instanceSettings, backendSrv, {}, templateSrv);
+  });
 
   describe('When performing annotationQuery', function() {
     let results;
@@ -59,26 +52,25 @@ describe('MySQLDatasource', function() {
     };
 
     beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(options) {
-        return ctx.$q.when({ data: response, status: 200 });
-      };
+      ctx.backendSrv.datasourceRequest = jest.fn(options => {
+        return Promise.resolve({ data: response, status: 200 });
+      });
       ctx.ds.annotationQuery(options).then(function(data) {
         results = data;
       });
-      ctx.$rootScope.$apply();
     });
 
     it('should return annotation list', function() {
-      expect(results.length).to.be(3);
+      expect(results.length).toBe(3);
 
-      expect(results[0].text).to.be('some text');
-      expect(results[0].tags[0]).to.be('TagA');
-      expect(results[0].tags[1]).to.be('TagB');
+      expect(results[0].text).toBe('some text');
+      expect(results[0].tags[0]).toBe('TagA');
+      expect(results[0].tags[1]).toBe('TagB');
 
-      expect(results[1].tags[0]).to.be('TagB');
-      expect(results[1].tags[1]).to.be('TagC');
+      expect(results[1].tags[0]).toBe('TagB');
+      expect(results[1].tags[1]).toBe('TagC');
 
-      expect(results[2].tags.length).to.be(0);
+      expect(results[2].tags.length).toBe(0);
     });
   });
 
@@ -103,19 +95,18 @@ describe('MySQLDatasource', function() {
     };
 
     beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(options) {
-        return ctx.$q.when({ data: response, status: 200 });
-      };
+      ctx.backendSrv.datasourceRequest = jest.fn(options => {
+        return Promise.resolve({ data: response, status: 200 });
+      });
       ctx.ds.metricFindQuery(query).then(function(data) {
         results = data;
       });
-      ctx.$rootScope.$apply();
     });
 
     it('should return list of all column values', function() {
-      expect(results.length).to.be(6);
-      expect(results[0].text).to.be('aTitle');
-      expect(results[5].text).to.be('some text3');
+      expect(results.length).toBe(6);
+      expect(results[0].text).toBe('aTitle');
+      expect(results[5].text).toBe('some text3');
     });
   });
 
@@ -140,21 +131,20 @@ describe('MySQLDatasource', function() {
     };
 
     beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(options) {
-        return ctx.$q.when({ data: response, status: 200 });
-      };
+      ctx.backendSrv.datasourceRequest = jest.fn(options => {
+        return Promise.resolve({ data: response, status: 200 });
+      });
       ctx.ds.metricFindQuery(query).then(function(data) {
         results = data;
       });
-      ctx.$rootScope.$apply();
     });
 
     it('should return list of as text, value', function() {
-      expect(results.length).to.be(3);
-      expect(results[0].text).to.be('aTitle');
-      expect(results[0].value).to.be('value1');
-      expect(results[2].text).to.be('aTitle3');
-      expect(results[2].value).to.be('value3');
+      expect(results.length).toBe(3);
+      expect(results[0].text).toBe('aTitle');
+      expect(results[0].value).toBe('value1');
+      expect(results[2].text).toBe('aTitle3');
+      expect(results[2].value).toBe('value3');
     });
   });
 
@@ -179,19 +169,18 @@ describe('MySQLDatasource', function() {
     };
 
     beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(options) {
-        return ctx.$q.when({ data: response, status: 200 });
-      };
+      ctx.backendSrv.datasourceRequest = jest.fn(options => {
+        return Promise.resolve({ data: response, status: 200 });
+      });
       ctx.ds.metricFindQuery(query).then(function(data) {
         results = data;
       });
-      ctx.$rootScope.$apply();
     });
 
     it('should return list of unique keys', function() {
-      expect(results.length).to.be(1);
-      expect(results[0].text).to.be('aTitle');
-      expect(results[0].value).to.be('same');
+      expect(results.length).toBe(1);
+      expect(results[0].text).toBe('aTitle');
+      expect(results[0].value).toBe('same');
     });
   });
 
@@ -202,33 +191,33 @@ describe('MySQLDatasource', function() {
 
     describe('and value is a string', () => {
       it('should return an unquoted value', () => {
-        expect(ctx.ds.interpolateVariable('abc', ctx.variable)).to.eql('abc');
+        expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual('abc');
       });
     });
 
     describe('and value is a number', () => {
       it('should return an unquoted value', () => {
-        expect(ctx.ds.interpolateVariable(1000, ctx.variable)).to.eql(1000);
+        expect(ctx.ds.interpolateVariable(1000, ctx.variable)).toEqual(1000);
       });
     });
 
     describe('and value is an array of strings', () => {
       it('should return comma separated quoted values', () => {
-        expect(ctx.ds.interpolateVariable(['a', 'b', 'c'], ctx.variable)).to.eql("'a','b','c'");
+        expect(ctx.ds.interpolateVariable(['a', 'b', 'c'], ctx.variable)).toEqual("'a','b','c'");
       });
     });
 
     describe('and variable allows multi-value and value is a string', () => {
       it('should return a quoted value', () => {
         ctx.variable.multi = true;
-        expect(ctx.ds.interpolateVariable('abc', ctx.variable)).to.eql("'abc'");
+        expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual("'abc'");
       });
     });
 
     describe('and variable allows all and value is a string', () => {
       it('should return a quoted value', () => {
         ctx.variable.includeAll = true;
-        expect(ctx.ds.interpolateVariable('abc', ctx.variable)).to.eql("'abc'");
+        expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual("'abc'");
       });
     });
   });

+ 50 - 60
public/app/plugins/datasource/postgres/specs/datasource_specs.ts → public/app/plugins/datasource/postgres/specs/datasource.jest.ts

@@ -1,28 +1,21 @@
-import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
 import moment from 'moment';
-import helpers from 'test/specs/helpers';
 import { PostgresDatasource } from '../datasource';
 import { CustomVariable } from 'app/features/templating/custom_variable';
 
 describe('PostgreSQLDatasource', function() {
-  var ctx = new helpers.ServiceTestContext();
-  var instanceSettings = { name: 'postgresql' };
-
-  beforeEach(angularMocks.module('grafana.core'));
-  beforeEach(angularMocks.module('grafana.services'));
-  beforeEach(ctx.providePhase(['backendSrv']));
-
-  beforeEach(
-    angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) {
-      ctx.$q = $q;
-      ctx.$httpBackend = $httpBackend;
-      ctx.$rootScope = $rootScope;
-      ctx.ds = $injector.instantiate(PostgresDatasource, {
-        instanceSettings: instanceSettings,
-      });
-      $httpBackend.when('GET', /\.html$/).respond('');
-    })
-  );
+  let instanceSettings = { name: 'postgresql' };
+
+  let backendSrv = {};
+  let templateSrv = {
+    replace: jest.fn(text => text),
+  };
+  let ctx = <any>{
+    backendSrv,
+  };
+
+  beforeEach(() => {
+    ctx.ds = new PostgresDatasource(instanceSettings, backendSrv, {}, templateSrv);
+  });
 
   describe('When performing annotationQuery', function() {
     let results;
@@ -59,26 +52,25 @@ describe('PostgreSQLDatasource', function() {
     };
 
     beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(options) {
-        return ctx.$q.when({ data: response, status: 200 });
-      };
+      ctx.backendSrv.datasourceRequest = jest.fn(options => {
+        return Promise.resolve({ data: response, status: 200 });
+      });
       ctx.ds.annotationQuery(options).then(function(data) {
         results = data;
       });
-      ctx.$rootScope.$apply();
     });
 
     it('should return annotation list', function() {
-      expect(results.length).to.be(3);
+      expect(results.length).toBe(3);
 
-      expect(results[0].text).to.be('some text');
-      expect(results[0].tags[0]).to.be('TagA');
-      expect(results[0].tags[1]).to.be('TagB');
+      expect(results[0].text).toBe('some text');
+      expect(results[0].tags[0]).toBe('TagA');
+      expect(results[0].tags[1]).toBe('TagB');
 
-      expect(results[1].tags[0]).to.be('TagB');
-      expect(results[1].tags[1]).to.be('TagC');
+      expect(results[1].tags[0]).toBe('TagB');
+      expect(results[1].tags[1]).toBe('TagC');
 
-      expect(results[2].tags.length).to.be(0);
+      expect(results[2].tags.length).toBe(0);
     });
   });
 
@@ -103,19 +95,18 @@ describe('PostgreSQLDatasource', function() {
     };
 
     beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(options) {
-        return ctx.$q.when({ data: response, status: 200 });
-      };
+      ctx.backendSrv.datasourceRequest = jest.fn(options => {
+        return Promise.resolve({ data: response, status: 200 });
+      });
       ctx.ds.metricFindQuery(query).then(function(data) {
         results = data;
       });
-      ctx.$rootScope.$apply();
     });
 
     it('should return list of all column values', function() {
-      expect(results.length).to.be(6);
-      expect(results[0].text).to.be('aTitle');
-      expect(results[5].text).to.be('some text3');
+      expect(results.length).toBe(6);
+      expect(results[0].text).toBe('aTitle');
+      expect(results[5].text).toBe('some text3');
     });
   });
 
@@ -140,21 +131,20 @@ describe('PostgreSQLDatasource', function() {
     };
 
     beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(options) {
-        return ctx.$q.when({ data: response, status: 200 });
-      };
+      ctx.backendSrv.datasourceRequest = jest.fn(options => {
+        return Promise.resolve({ data: response, status: 200 });
+      });
       ctx.ds.metricFindQuery(query).then(function(data) {
         results = data;
       });
-      ctx.$rootScope.$apply();
     });
 
     it('should return list of as text, value', function() {
-      expect(results.length).to.be(3);
-      expect(results[0].text).to.be('aTitle');
-      expect(results[0].value).to.be('value1');
-      expect(results[2].text).to.be('aTitle3');
-      expect(results[2].value).to.be('value3');
+      expect(results.length).toBe(3);
+      expect(results[0].text).toBe('aTitle');
+      expect(results[0].value).toBe('value1');
+      expect(results[2].text).toBe('aTitle3');
+      expect(results[2].value).toBe('value3');
     });
   });
 
@@ -178,20 +168,20 @@ describe('PostgreSQLDatasource', function() {
       },
     };
 
-    beforeEach(function() {
-      ctx.backendSrv.datasourceRequest = function(options) {
-        return ctx.$q.when({ data: response, status: 200 });
-      };
+    beforeEach(() => {
+      ctx.backendSrv.datasourceRequest = jest.fn(options => {
+        return Promise.resolve({ data: response, status: 200 });
+      });
       ctx.ds.metricFindQuery(query).then(function(data) {
         results = data;
       });
-      ctx.$rootScope.$apply();
+      //ctx.$rootScope.$apply();
     });
 
     it('should return list of unique keys', function() {
-      expect(results.length).to.be(1);
-      expect(results[0].text).to.be('aTitle');
-      expect(results[0].value).to.be('same');
+      expect(results.length).toBe(1);
+      expect(results[0].text).toBe('aTitle');
+      expect(results[0].value).toBe('same');
     });
   });
 
@@ -202,33 +192,33 @@ describe('PostgreSQLDatasource', function() {
 
     describe('and value is a string', () => {
       it('should return an unquoted value', () => {
-        expect(ctx.ds.interpolateVariable('abc', ctx.variable)).to.eql('abc');
+        expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual('abc');
       });
     });
 
     describe('and value is a number', () => {
       it('should return an unquoted value', () => {
-        expect(ctx.ds.interpolateVariable(1000, ctx.variable)).to.eql(1000);
+        expect(ctx.ds.interpolateVariable(1000, ctx.variable)).toEqual(1000);
       });
     });
 
     describe('and value is an array of strings', () => {
       it('should return comma separated quoted values', () => {
-        expect(ctx.ds.interpolateVariable(['a', 'b', 'c'], ctx.variable)).to.eql("'a','b','c'");
+        expect(ctx.ds.interpolateVariable(['a', 'b', 'c'], ctx.variable)).toEqual("'a','b','c'");
       });
     });
 
     describe('and variable allows multi-value and is a string', () => {
       it('should return a quoted value', () => {
         ctx.variable.multi = true;
-        expect(ctx.ds.interpolateVariable('abc', ctx.variable)).to.eql("'abc'");
+        expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual("'abc'");
       });
     });
 
     describe('and variable allows all and is a string', () => {
       it('should return a quoted value', () => {
         ctx.variable.includeAll = true;
-        expect(ctx.ds.interpolateVariable('abc', ctx.variable)).to.eql("'abc'");
+        expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual("'abc'");
       });
     });
   });

+ 1 - 1
public/app/plugins/datasource/prometheus/datasource.ts

@@ -196,7 +196,7 @@ export class PrometheusDatasource {
       interval = adjustedInterval;
       scopedVars = Object.assign({}, options.scopedVars, {
         __interval: { text: interval + 's', value: interval + 's' },
-        __interval_ms: { text: interval * 1000, value: interval * 1000 },
+        __interval_ms: { text: String(interval * 1000), value: String(interval * 1000) },
       });
     }
     query.step = interval;

+ 18 - 18
public/app/plugins/datasource/prometheus/specs/datasource_specs.ts

@@ -452,7 +452,7 @@ describe('PrometheusDatasource', function() {
         interval: '10s',
         scopedVars: {
           __interval: { text: '10s', value: '10s' },
-          __interval_ms: { text: 10 * 1000, value: 10 * 1000 },
+          __interval_ms: { text: String(10 * 1000), value: String(10 * 1000) },
         },
       };
       var urlExpected =
@@ -463,8 +463,8 @@ describe('PrometheusDatasource', function() {
 
       expect(query.scopedVars.__interval.text).to.be('10s');
       expect(query.scopedVars.__interval.value).to.be('10s');
-      expect(query.scopedVars.__interval_ms.text).to.be(10 * 1000);
-      expect(query.scopedVars.__interval_ms.value).to.be(10 * 1000);
+      expect(query.scopedVars.__interval_ms.text).to.be(String(10 * 1000));
+      expect(query.scopedVars.__interval_ms.value).to.be(String(10 * 1000));
     });
     it('should be min interval when it is greater than auto interval', function() {
       var query = {
@@ -479,7 +479,7 @@ describe('PrometheusDatasource', function() {
         interval: '5s',
         scopedVars: {
           __interval: { text: '5s', value: '5s' },
-          __interval_ms: { text: 5 * 1000, value: 5 * 1000 },
+          __interval_ms: { text: String(5 * 1000), value: String(5 * 1000) },
         },
       };
       var urlExpected =
@@ -490,8 +490,8 @@ describe('PrometheusDatasource', function() {
 
       expect(query.scopedVars.__interval.text).to.be('5s');
       expect(query.scopedVars.__interval.value).to.be('5s');
-      expect(query.scopedVars.__interval_ms.text).to.be(5 * 1000);
-      expect(query.scopedVars.__interval_ms.value).to.be(5 * 1000);
+      expect(query.scopedVars.__interval_ms.text).to.be(String(5 * 1000));
+      expect(query.scopedVars.__interval_ms.value).to.be(String(5 * 1000));
     });
     it('should account for intervalFactor', function() {
       var query = {
@@ -507,7 +507,7 @@ describe('PrometheusDatasource', function() {
         interval: '10s',
         scopedVars: {
           __interval: { text: '10s', value: '10s' },
-          __interval_ms: { text: 10 * 1000, value: 10 * 1000 },
+          __interval_ms: { text: String(10 * 1000), value: String(10 * 1000) },
         },
       };
       var urlExpected =
@@ -518,8 +518,8 @@ describe('PrometheusDatasource', function() {
 
       expect(query.scopedVars.__interval.text).to.be('10s');
       expect(query.scopedVars.__interval.value).to.be('10s');
-      expect(query.scopedVars.__interval_ms.text).to.be(10 * 1000);
-      expect(query.scopedVars.__interval_ms.value).to.be(10 * 1000);
+      expect(query.scopedVars.__interval_ms.text).to.be(String(10 * 1000));
+      expect(query.scopedVars.__interval_ms.value).to.be(String(10 * 1000));
     });
     it('should be interval * intervalFactor when greater than min interval', function() {
       var query = {
@@ -535,7 +535,7 @@ describe('PrometheusDatasource', function() {
         interval: '5s',
         scopedVars: {
           __interval: { text: '5s', value: '5s' },
-          __interval_ms: { text: 5 * 1000, value: 5 * 1000 },
+          __interval_ms: { text: String(5 * 1000), value: String(5 * 1000) },
         },
       };
       var urlExpected =
@@ -546,8 +546,8 @@ describe('PrometheusDatasource', function() {
 
       expect(query.scopedVars.__interval.text).to.be('5s');
       expect(query.scopedVars.__interval.value).to.be('5s');
-      expect(query.scopedVars.__interval_ms.text).to.be(5 * 1000);
-      expect(query.scopedVars.__interval_ms.value).to.be(5 * 1000);
+      expect(query.scopedVars.__interval_ms.text).to.be(String(5 * 1000));
+      expect(query.scopedVars.__interval_ms.value).to.be(String(5 * 1000));
     });
     it('should be min interval when greater than interval * intervalFactor', function() {
       var query = {
@@ -563,7 +563,7 @@ describe('PrometheusDatasource', function() {
         interval: '5s',
         scopedVars: {
           __interval: { text: '5s', value: '5s' },
-          __interval_ms: { text: 5 * 1000, value: 5 * 1000 },
+          __interval_ms: { text: String(5 * 1000), value: String(5 * 1000) },
         },
       };
       var urlExpected =
@@ -574,8 +574,8 @@ describe('PrometheusDatasource', function() {
 
       expect(query.scopedVars.__interval.text).to.be('5s');
       expect(query.scopedVars.__interval.value).to.be('5s');
-      expect(query.scopedVars.__interval_ms.text).to.be(5 * 1000);
-      expect(query.scopedVars.__interval_ms.value).to.be(5 * 1000);
+      expect(query.scopedVars.__interval_ms.text).to.be(String(5 * 1000));
+      expect(query.scopedVars.__interval_ms.value).to.be(String(5 * 1000));
     });
     it('should be determined by the 11000 data points limit, accounting for intervalFactor', function() {
       var query = {
@@ -590,7 +590,7 @@ describe('PrometheusDatasource', function() {
         interval: '5s',
         scopedVars: {
           __interval: { text: '5s', value: '5s' },
-          __interval_ms: { text: 5 * 1000, value: 5 * 1000 },
+          __interval_ms: { text: String(5 * 1000), value: String(5 * 1000) },
         },
       };
       var end = 7 * 24 * 60 * 60;
@@ -609,8 +609,8 @@ describe('PrometheusDatasource', function() {
 
       expect(query.scopedVars.__interval.text).to.be('5s');
       expect(query.scopedVars.__interval.value).to.be('5s');
-      expect(query.scopedVars.__interval_ms.text).to.be(5 * 1000);
-      expect(query.scopedVars.__interval_ms.value).to.be(5 * 1000);
+      expect(query.scopedVars.__interval_ms.text).to.be(String(5 * 1000));
+      expect(query.scopedVars.__interval_ms.value).to.be(String(5 * 1000));
     });
   });
 });

+ 1 - 1
public/app/plugins/panel/heatmap/color_scale.ts

@@ -3,7 +3,7 @@ import * as d3ScaleChromatic from 'd3-scale-chromatic';
 
 export function getColorScale(colorScheme: any, lightTheme: boolean, maxValue: number, minValue = 0): (d: any) => any {
   let colorInterpolator = d3ScaleChromatic[colorScheme.value];
-  let colorScaleInverted = colorScheme.invert === 'always' || (colorScheme.invert === 'dark' && !lightTheme);
+  let colorScaleInverted = colorScheme.invert === 'always' || colorScheme.invert === (lightTheme ? 'light' : 'dark');
 
   let start = colorScaleInverted ? maxValue : minValue;
   let end = colorScaleInverted ? minValue : maxValue;

+ 8 - 1
public/app/plugins/panel/heatmap/heatmap_ctrl.ts

@@ -76,6 +76,13 @@ let colorSchemes = [
   { name: 'Reds', value: 'interpolateReds', invert: 'dark' },
 
   // Sequential (Multi-Hue)
+  { name: 'Viridis', value: 'interpolateViridis', invert: 'light' },
+  { name: 'Magma', value: 'interpolateMagma', invert: 'light' },
+  { name: 'Inferno', value: 'interpolateInferno', invert: 'light' },
+  { name: 'Plasma', value: 'interpolatePlasma', invert: 'light' },
+  { name: 'Warm', value: 'interpolateWarm', invert: 'light' },
+  { name: 'Cool', value: 'interpolateCool', invert: 'light' },
+  { name: 'Cubehelix', value: 'interpolateCubehelixDefault', invert: 'light' },
   { name: 'BuGn', value: 'interpolateBuGn', invert: 'dark' },
   { name: 'BuPu', value: 'interpolateBuPu', invert: 'dark' },
   { name: 'GnBu', value: 'interpolateGnBu', invert: 'dark' },
@@ -87,7 +94,7 @@ let colorSchemes = [
   { name: 'YlGnBu', value: 'interpolateYlGnBu', invert: 'dark' },
   { name: 'YlGn', value: 'interpolateYlGn', invert: 'dark' },
   { name: 'YlOrBr', value: 'interpolateYlOrBr', invert: 'dark' },
-  { name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'darm' },
+  { name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'dark' },
 ];
 
 const ds_support_histogram_sort = ['prometheus', 'elasticsearch'];

+ 13 - 7
public/app/routes/routes.ts

@@ -5,6 +5,8 @@ import ServerStats from 'app/containers/ServerStats/ServerStats';
 import AlertRuleList from 'app/containers/AlertRuleList/AlertRuleList';
 import FolderSettings from 'app/containers/ManageDashboards/FolderSettings';
 import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions';
+import TeamPages from 'app/containers/Teams/TeamPages';
+import TeamList from 'app/containers/Teams/TeamList';
 
 /** @ngInject **/
 export function setupAngularRoutes($routeProvider, $locationProvider) {
@@ -140,19 +142,23 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
       controller: 'OrgApiKeysCtrl',
     })
     .when('/org/teams', {
-      templateUrl: 'public/app/features/org/partials/teams.html',
-      controller: 'TeamsCtrl',
-      controllerAs: 'ctrl',
+      template: '<react-container />',
+      resolve: {
+        roles: () => ['Editor', 'Admin'],
+        component: () => TeamList,
+      },
     })
     .when('/org/teams/new', {
       templateUrl: 'public/app/features/org/partials/create_team.html',
       controller: 'CreateTeamCtrl',
       controllerAs: 'ctrl',
     })
-    .when('/org/teams/edit/:id', {
-      templateUrl: 'public/app/features/org/partials/team_details.html',
-      controller: 'TeamDetailsCtrl',
-      controllerAs: 'ctrl',
+    .when('/org/teams/edit/:id/:page?', {
+      template: '<react-container />',
+      resolve: {
+        roles: () => ['Admin'],
+        component: () => TeamPages,
+      },
     })
     .when('/profile', {
       templateUrl: 'public/app/features/org/partials/profile.html',

+ 2 - 1
public/app/stores/NavStore/NavItem.ts

@@ -1,4 +1,4 @@
-import { types } from 'mobx-state-tree';
+import { types } from 'mobx-state-tree';
 
 export const NavItem = types.model('NavItem', {
   id: types.identifier(types.string),
@@ -8,6 +8,7 @@ export const NavItem = types.model('NavItem', {
   icon: types.optional(types.string, ''),
   img: types.optional(types.string, ''),
   active: types.optional(types.boolean, false),
+  hideFromTabs: types.optional(types.boolean, false),
   breadcrumbs: types.optional(types.array(types.late(() => Breadcrumb)), []),
   children: types.optional(types.array(types.late(() => NavItem)), []),
 });

+ 40 - 0
public/app/stores/NavStore/NavStore.ts

@@ -1,6 +1,7 @@
 import _ from 'lodash';
 import { types, getEnv } from 'mobx-state-tree';
 import { NavItem } from './NavItem';
+import { ITeam } from '../TeamsStore/TeamsStore';
 
 export const NavStore = types
   .model('NavStore', {
@@ -115,4 +116,43 @@ export const NavStore = types
 
       self.main = NavItem.create(main);
     },
+
+    initTeamPage(team: ITeam, tab: string, isSyncEnabled: boolean) {
+      let main = {
+        img: team.avatarUrl,
+        id: 'team-' + team.id,
+        subTitle: 'Manage members & settings',
+        url: '',
+        text: team.name,
+        breadcrumbs: [{ title: 'Teams', url: 'org/teams' }],
+        children: [
+          {
+            active: tab === 'members',
+            icon: 'gicon gicon-team',
+            id: 'team-members',
+            text: 'Members',
+            url: `org/teams/edit/${team.id}/members`,
+          },
+          {
+            active: tab === 'settings',
+            icon: 'fa fa-fw fa-sliders',
+            id: 'team-settings',
+            text: 'Settings',
+            url: `org/teams/edit/${team.id}/settings`,
+          },
+        ],
+      };
+
+      if (isSyncEnabled) {
+        main.children.splice(1, 0, {
+          active: tab === 'groupsync',
+          icon: 'fa fa-fw fa-refresh',
+          id: 'team-settings',
+          text: 'External group sync',
+          url: `org/teams/edit/${team.id}/groupsync`,
+        });
+      }
+
+      self.main = NavItem.create(main);
+    },
   }));

+ 4 - 0
public/app/stores/RootStore/RootStore.ts

@@ -6,6 +6,7 @@ import { AlertListStore } from './../AlertListStore/AlertListStore';
 import { ViewStore } from './../ViewStore/ViewStore';
 import { FolderStore } from './../FolderStore/FolderStore';
 import { PermissionsStore } from './../PermissionsStore/PermissionsStore';
+import { TeamsStore } from './../TeamsStore/TeamsStore';
 
 export const RootStore = types.model({
   search: types.optional(SearchStore, {
@@ -28,6 +29,9 @@ export const RootStore = types.model({
     routeParams: {},
   }),
   folder: types.optional(FolderStore, {}),
+  teams: types.optional(TeamsStore, {
+    map: {},
+  }),
 });
 
 type IRootStoreType = typeof RootStore.Type;

+ 156 - 0
public/app/stores/TeamsStore/TeamsStore.ts

@@ -0,0 +1,156 @@
+import { types, getEnv, flow } from 'mobx-state-tree';
+
+export const TeamMember = types.model('TeamMember', {
+  userId: types.identifier(types.number),
+  teamId: types.number,
+  avatarUrl: types.string,
+  email: types.string,
+  login: types.string,
+});
+
+type TeamMemberType = typeof TeamMember.Type;
+export interface ITeamMember extends TeamMemberType {}
+
+export const TeamGroup = types.model('TeamGroup', {
+  groupId: types.identifier(types.string),
+  teamId: types.number,
+});
+
+type TeamGroupType = typeof TeamGroup.Type;
+export interface ITeamGroup extends TeamGroupType {}
+
+export const Team = types
+  .model('Team', {
+    id: types.identifier(types.number),
+    name: types.string,
+    avatarUrl: types.string,
+    email: types.string,
+    memberCount: types.number,
+    search: types.optional(types.string, ''),
+    members: types.optional(types.map(TeamMember), {}),
+    groups: types.optional(types.map(TeamGroup), {}),
+  })
+  .views(self => ({
+    get filteredMembers() {
+      let members = this.members.values();
+      let regex = new RegExp(self.search, 'i');
+      return members.filter(member => {
+        return regex.test(member.login) || regex.test(member.email);
+      });
+    },
+  }))
+  .actions(self => ({
+    setName(name: string) {
+      self.name = name;
+    },
+
+    setEmail(email: string) {
+      self.email = email;
+    },
+
+    setSearchQuery(query: string) {
+      self.search = query;
+    },
+
+    update: flow(function* load() {
+      const backendSrv = getEnv(self).backendSrv;
+
+      yield backendSrv.put(`/api/teams/${self.id}`, {
+        name: self.name,
+        email: self.email,
+      });
+    }),
+
+    loadMembers: flow(function* load() {
+      const backendSrv = getEnv(self).backendSrv;
+      const rsp = yield backendSrv.get(`/api/teams/${self.id}/members`);
+      self.members.clear();
+
+      for (let member of rsp) {
+        self.members.set(member.userId.toString(), TeamMember.create(member));
+      }
+    }),
+
+    removeMember: flow(function* load(member: ITeamMember) {
+      const backendSrv = getEnv(self).backendSrv;
+      yield backendSrv.delete(`/api/teams/${self.id}/members/${member.userId}`);
+      // remove from store map
+      self.members.delete(member.userId.toString());
+    }),
+
+    addMember: flow(function* load(userId: number) {
+      const backendSrv = getEnv(self).backendSrv;
+      yield backendSrv.post(`/api/teams/${self.id}/members`, { userId: userId });
+    }),
+
+    loadGroups: flow(function* load() {
+      const backendSrv = getEnv(self).backendSrv;
+      const rsp = yield backendSrv.get(`/api/teams/${self.id}/groups`);
+      self.groups.clear();
+
+      for (let group of rsp) {
+        self.groups.set(group.groupId, TeamGroup.create(group));
+      }
+    }),
+
+    addGroup: flow(function* load(groupId: string) {
+      const backendSrv = getEnv(self).backendSrv;
+      yield backendSrv.post(`/api/teams/${self.id}/groups`, { groupId: groupId });
+      self.groups.set(
+        groupId,
+        TeamGroup.create({
+          teamId: self.id,
+          groupId: groupId,
+        })
+      );
+    }),
+
+    removeGroup: flow(function* load(groupId: string) {
+      const backendSrv = getEnv(self).backendSrv;
+      yield backendSrv.delete(`/api/teams/${self.id}/groups/${groupId}`);
+      self.groups.delete(groupId);
+    }),
+  }));
+
+type TeamType = typeof Team.Type;
+export interface ITeam extends TeamType {}
+
+export const TeamsStore = types
+  .model('TeamsStore', {
+    map: types.map(Team),
+    search: types.optional(types.string, ''),
+  })
+  .views(self => ({
+    get filteredTeams() {
+      let teams = this.map.values();
+      let regex = new RegExp(self.search, 'i');
+      return teams.filter(team => {
+        return regex.test(team.name);
+      });
+    },
+  }))
+  .actions(self => ({
+    loadTeams: flow(function* load() {
+      const backendSrv = getEnv(self).backendSrv;
+      const rsp = yield backendSrv.get('/api/teams/search/', { perpage: 50, page: 1 });
+      self.map.clear();
+
+      for (let team of rsp.teams) {
+        self.map.set(team.id.toString(), Team.create(team));
+      }
+    }),
+
+    setSearchQuery(query: string) {
+      self.search = query;
+    },
+
+    loadById: flow(function* load(id: string) {
+      if (self.map.has(id)) {
+        return;
+      }
+
+      const backendSrv = getEnv(self).backendSrv;
+      const team = yield backendSrv.get(`/api/teams/${id}`);
+      self.map.set(id, Team.create(team));
+    }),
+  }));

+ 2 - 2
public/sass/components/_gf-form.scss

@@ -403,9 +403,9 @@ select.gf-form-input ~ .gf-form-help-icon {
 
 .cta-form {
   position: relative;
-  padding: 1rem;
+  padding: 1.5rem;
   background-color: $empty-list-cta-bg;
-  margin-bottom: 1rem;
+  margin-bottom: 2rem;
   border-top: 3px solid $green;
 }
 

+ 8 - 0
public/sass/components/_navbar.scss

@@ -85,6 +85,14 @@
     // icon hidden on smaller screens
     display: none;
   }
+
+  &--folder {
+    color: $text-color-weak;
+
+    @include media-breakpoint-down(md) {
+      display: none;
+    }
+  }
 }
 
 .navbar-buttons {

+ 12 - 1
public/test/jest-shim.ts

@@ -1,6 +1,17 @@
 declare var global: NodeJS.Global;
 
-(<any>global).requestAnimationFrame = (callback) => {
+(<any>global).requestAnimationFrame = callback => {
   setTimeout(callback, 0);
 };
 
+(<any>Promise.prototype).finally = function(onFinally) {
+  return this.then(
+    /* onFulfilled */
+    res => Promise.resolve(onFinally()).then(() => res),
+    /* onRejected */
+    err =>
+      Promise.resolve(onFinally()).then(() => {
+        throw err;
+      })
+  );
+};

+ 0 - 3
scripts/circle-test-backend.sh

@@ -13,9 +13,6 @@ function exit_if_fail {
 echo "running go fmt"
 exit_if_fail test -z "$(gofmt -s -l ./pkg | tee /dev/stderr)"
 
-echo "running go vet"
-exit_if_fail test -z "$(go vet ./pkg/... | tee /dev/stderr)"
-
 echo "building backend with install to cache pkgs"
 exit_if_fail time go install ./pkg/cmd/grafana-server