Przeglądaj źródła

Teams page replace mobx (#13219)

* creating types, actions, reducer

* load teams and store in redux

* delete team

* set search query action and tests

* Teampages page

* team members, bug in fetching team

* flattened team state, tests for TeamMembers

* test for team member selector

* team settings

* actions for group sync

* tests for team groups

* removed comment

* remove old stores

* fix: formating of datasource.go

* fix: minor changes to imports

* adding debounce and fixing issue in teamlist

* refactoring: moving types to their own files
Peter Holmberg 7 lat temu
rodzic
commit
9f73f13091
42 zmienionych plików z 2486 dodań i 588 usunięć
  1. 0 77
      public/app/containers/Teams/TeamPages.tsx
  2. 0 69
      public/app/containers/Teams/TeamSettings.tsx
  3. 2 1
      public/app/core/actions/index.ts
  4. 11 7
      public/app/core/actions/navModel.ts
  5. 0 1
      public/app/core/components/CustomScrollbar/CustomScrollbar.tsx
  6. 16 2
      public/app/core/reducers/navModel.ts
  7. 3 0
      public/app/core/selectors/location.ts
  8. 4 4
      public/app/features/alerting/state/actions.ts
  9. 2 2
      public/app/features/alerting/state/reducers.test.ts
  10. 2 2
      public/app/features/alerting/state/reducers.ts
  11. 63 0
      public/app/features/teams/TeamGroupSync.test.tsx
  12. 51 36
      public/app/features/teams/TeamGroupSync.tsx
  13. 75 0
      public/app/features/teams/TeamList.test.tsx
  14. 68 58
      public/app/features/teams/TeamList.tsx
  15. 79 0
      public/app/features/teams/TeamMembers.test.tsx
  16. 50 37
      public/app/features/teams/TeamMembers.tsx
  17. 63 0
      public/app/features/teams/TeamPages.test.tsx
  18. 105 0
      public/app/features/teams/TeamPages.tsx
  19. 44 0
      public/app/features/teams/TeamSettings.test.tsx
  20. 96 0
      public/app/features/teams/TeamSettings.tsx
  21. 59 0
      public/app/features/teams/__mocks__/navModelMock.ts
  22. 65 0
      public/app/features/teams/__mocks__/teamMocks.ts
  23. 281 0
      public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap
  24. 354 0
      public/app/features/teams/__snapshots__/TeamList.test.tsx.snap
  25. 317 0
      public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap
  26. 48 0
      public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap
  27. 57 0
      public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap
  28. 237 0
      public/app/features/teams/state/actions.ts
  29. 72 0
      public/app/features/teams/state/reducers.test.ts
  30. 44 0
      public/app/features/teams/state/reducers.ts
  31. 56 0
      public/app/features/teams/state/selectors.test.ts
  32. 30 0
      public/app/features/teams/state/selectors.ts
  33. 2 2
      public/app/routes/routes.ts
  34. 0 40
      public/app/stores/NavStore/NavStore.ts
  35. 0 4
      public/app/stores/RootStore/RootStore.ts
  36. 0 156
      public/app/stores/TeamsStore/TeamsStore.ts
  37. 2 0
      public/app/stores/configureStore.ts
  38. 35 0
      public/app/types/alerting.ts
  39. 24 90
      public/app/types/index.ts
  40. 15 0
      public/app/types/location.ts
  41. 22 0
      public/app/types/navModel.ts
  42. 32 0
      public/app/types/teams.ts

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

@@ -1,77 +0,0 @@
-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, Team } 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(): Team {
-    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);

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

@@ -1,69 +0,0 @@
-import React from 'react';
-import { hot } from 'react-hot-loader';
-import { observer } from 'mobx-react';
-import { Team } from 'app/stores/TeamsStore/TeamsStore';
-import { Label } from 'app/core/components/Forms/Forms';
-
-interface Props {
-  team: Team;
-}
-
-@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);

+ 2 - 1
public/app/core/actions/index.ts

@@ -1,3 +1,4 @@
 import { updateLocation } from './location';
+import { updateNavIndex, UpdateNavIndexAction } from './navModel';
 
-export { updateLocation };
+export { updateLocation, updateNavIndex, UpdateNavIndexAction };

+ 11 - 7
public/app/core/actions/navModel.ts

@@ -1,13 +1,17 @@
-export type Action = UpdateNavIndexAction;
+import { NavModelItem } from '../../types';
+
+export enum ActionTypes {
+  UpdateNavIndex = 'UPDATE_NAV_INDEX',
+}
 
-// this action is not used yet
-// kind of just a placeholder, will be need for dynamic pages
-// like datasource edit, teams edit page
+export type Action = UpdateNavIndexAction;
 
 export interface UpdateNavIndexAction {
-  type: 'UPDATE_NAV_INDEX';
+  type: ActionTypes.UpdateNavIndex;
+  payload: NavModelItem;
 }
 
-export const updateNavIndex = (): UpdateNavIndexAction => ({
-  type: 'UPDATE_NAV_INDEX',
+export const updateNavIndex = (item: NavModelItem): UpdateNavIndexAction => ({
+  type: ActionTypes.UpdateNavIndex,
+  payload: item,
 });

+ 0 - 1
public/app/core/components/CustomScrollbar/CustomScrollbar.tsx

@@ -13,7 +13,6 @@ interface Props {
  * Wraps component into <Scrollbars> component from `react-custom-scrollbars`
  */
 class CustomScrollbar extends PureComponent<Props> {
-
   static defaultProps: Partial<Props> = {
     customClassName: 'custom-scrollbars',
     autoHide: true,

+ 16 - 2
public/app/core/reducers/navModel.ts

@@ -1,5 +1,5 @@
-import { Action } from 'app/core/actions/navModel';
-import { NavModelItem, NavIndex } from 'app/types';
+import { Action, ActionTypes } from 'app/core/actions/navModel';
+import { NavIndex, NavModelItem } from 'app/types';
 import config from 'app/core/config';
 
 export function buildInitialState(): NavIndex {
@@ -25,5 +25,19 @@ function buildNavIndex(navIndex: NavIndex, children: NavModelItem[], parentItem?
 export const initialState: NavIndex = buildInitialState();
 
 export const navIndexReducer = (state = initialState, action: Action): NavIndex => {
+  switch (action.type) {
+    case ActionTypes.UpdateNavIndex:
+      const newPages = {};
+      const payload = action.payload;
+
+      for (const node of payload.children) {
+        newPages[node.id] = {
+          ...node,
+          parentItem: payload,
+        };
+      }
+
+      return { ...state, ...newPages };
+  }
   return state;
 };

+ 3 - 0
public/app/core/selectors/location.ts

@@ -0,0 +1,3 @@
+export const getRouteParamsId = state => state.routeParams.id;
+
+export const getRouteParamsPage = state => state.routeParams.page;

+ 4 - 4
public/app/features/alerting/state/actions.ts

@@ -1,15 +1,15 @@
 import { getBackendSrv } from 'app/core/services/backend_srv';
-import { AlertRuleApi, StoreState } from 'app/types';
+import { AlertRuleDTO, StoreState } from 'app/types';
 import { ThunkAction } from 'redux-thunk';
 
 export enum ActionTypes {
   LoadAlertRules = 'LOAD_ALERT_RULES',
-  SetSearchQuery = 'SET_SEARCH_QUERY',
+  SetSearchQuery = 'SET_ALERT_SEARCH_QUERY',
 }
 
 export interface LoadAlertRulesAction {
   type: ActionTypes.LoadAlertRules;
-  payload: AlertRuleApi[];
+  payload: AlertRuleDTO[];
 }
 
 export interface SetSearchQueryAction {
@@ -17,7 +17,7 @@ export interface SetSearchQueryAction {
   payload: string;
 }
 
-export const loadAlertRules = (rules: AlertRuleApi[]): LoadAlertRulesAction => ({
+export const loadAlertRules = (rules: AlertRuleDTO[]): LoadAlertRulesAction => ({
   type: ActionTypes.LoadAlertRules,
   payload: rules,
 });

+ 2 - 2
public/app/features/alerting/state/reducers.test.ts

@@ -1,9 +1,9 @@
 import { ActionTypes, Action } from './actions';
 import { alertRulesReducer, initialState } from './reducers';
-import { AlertRuleApi } from '../../../types';
+import { AlertRuleDTO } from 'app/types';
 
 describe('Alert rules', () => {
-  const payload: AlertRuleApi[] = [
+  const payload: AlertRuleDTO[] = [
     {
       id: 2,
       dashboardId: 7,

+ 2 - 2
public/app/features/alerting/state/reducers.ts

@@ -1,5 +1,5 @@
 import moment from 'moment';
-import { AlertRuleApi, AlertRule, AlertRulesState } from 'app/types';
+import { AlertRuleDTO, AlertRule, AlertRulesState } from 'app/types';
 import { Action, ActionTypes } from './actions';
 import alertDef from './alertDef';
 
@@ -29,7 +29,7 @@ function convertToAlertRule(rule, state): AlertRule {
 export const alertRulesReducer = (state = initialState, action: Action): AlertRulesState => {
   switch (action.type) {
     case ActionTypes.LoadAlertRules: {
-      const alertRules: AlertRuleApi[] = action.payload;
+      const alertRules: AlertRuleDTO[] = action.payload;
 
       const alertRulesViewModel: AlertRule[] = alertRules.map(rule => {
         return convertToAlertRule(rule, rule.state);

+ 63 - 0
public/app/features/teams/TeamGroupSync.test.tsx

@@ -0,0 +1,63 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { Props, TeamGroupSync } from './TeamGroupSync';
+import { TeamGroup } from '../../types';
+import { getMockTeamGroups } from './__mocks__/teamMocks';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    groups: [] as TeamGroup[],
+    loadTeamGroups: jest.fn(),
+    addTeamGroup: jest.fn(),
+    removeTeamGroup: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<TeamGroupSync {...props} />);
+  const instance = wrapper.instance() as TeamGroupSync;
+
+  return {
+    wrapper,
+    instance,
+  };
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const { wrapper } = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render groups table', () => {
+    const { wrapper } = setup({
+      groups: getMockTeamGroups(3),
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe('Functions', () => {
+  it('should call add group', () => {
+    const { instance } = setup();
+
+    instance.setState({ newGroupId: 'some/group' });
+    const mockEvent = { preventDefault: jest.fn() };
+
+    instance.onAddGroup(mockEvent);
+
+    expect(instance.props.addTeamGroup).toHaveBeenCalledWith('some/group');
+  });
+
+  it('should call remove group', () => {
+    const { instance } = setup();
+
+    const mockGroup: TeamGroup = { teamId: 1, groupId: 'some/group' };
+
+    instance.onRemoveGroup(mockGroup);
+
+    expect(instance.props.removeTeamGroup).toHaveBeenCalledWith('some/group');
+  });
+});

+ 51 - 36
public/app/containers/Teams/TeamGroupSync.tsx → public/app/features/teams/TeamGroupSync.tsx

@@ -1,12 +1,16 @@
-import React from 'react';
-import { hot } from 'react-hot-loader';
-import { observer } from 'mobx-react';
-import { Team, TeamGroup } from 'app/stores/TeamsStore/TeamsStore';
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
 import SlideDown from 'app/core/components/Animations/SlideDown';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
-
-interface Props {
-  team: Team;
+import { TeamGroup } from '../../types';
+import { addTeamGroup, loadTeamGroups, removeTeamGroup } from './state/actions';
+import { getTeamGroups } from './state/selectors';
+
+export interface Props {
+  groups: TeamGroup[];
+  loadTeamGroups: typeof loadTeamGroups;
+  addTeamGroup: typeof addTeamGroup;
+  removeTeamGroup: typeof removeTeamGroup;
 }
 
 interface State {
@@ -16,54 +20,58 @@ interface State {
 
 const headerTooltip = `Sync LDAP or OAuth groups with your Grafana teams.`;
 
-@observer
-export class TeamGroupSync extends React.Component<Props, State> {
+export class TeamGroupSync extends PureComponent<Props, State> {
   constructor(props) {
     super(props);
     this.state = { isAdding: false, newGroupId: '' };
   }
 
   componentDidMount() {
-    this.props.team.loadGroups();
+    this.fetchTeamGroups();
   }
 
-  renderGroup(group: TeamGroup) {
-    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>
-    );
+  async fetchTeamGroups() {
+    await this.props.loadTeamGroups();
   }
 
   onToggleAdding = () => {
     this.setState({ isAdding: !this.state.isAdding });
   };
 
-  onNewGroupIdChanged = evt => {
-    this.setState({ newGroupId: evt.target.value });
+  onNewGroupIdChanged = event => {
+    this.setState({ newGroupId: event.target.value });
   };
 
-  onAddGroup = () => {
-    this.props.team.addGroup(this.state.newGroupId);
+  onAddGroup = event => {
+    event.preventDefault();
+    this.props.addTeamGroup(this.state.newGroupId);
     this.setState({ isAdding: false, newGroupId: '' });
   };
 
   onRemoveGroup = (group: TeamGroup) => {
-    this.props.team.removeGroup(group.groupId);
+    this.props.removeTeamGroup(group.groupId);
   };
 
   isNewGroupValid() {
     return this.state.newGroupId.length > 1;
   }
 
+  renderGroup(group: TeamGroup) {
+    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>
+    );
+  }
+
   render() {
     const { isAdding, newGroupId } = this.state;
-    const groups = this.props.team.groups.values();
+    const groups = this.props.groups;
 
     return (
       <div>
@@ -86,7 +94,7 @@ export class TeamGroupSync extends React.Component<Props, State> {
               <i className="fa fa-close" />
             </button>
             <h5>Add External Group</h5>
-            <div className="gf-form-inline">
+            <form className="gf-form-inline" onSubmit={this.onAddGroup}>
               <div className="gf-form">
                 <input
                   type="text"
@@ -98,16 +106,11 @@ export class TeamGroupSync extends React.Component<Props, State> {
               </div>
 
               <div className="gf-form">
-                <button
-                  className="btn btn-success gf-form-btn"
-                  onClick={this.onAddGroup}
-                  type="submit"
-                  disabled={!this.isNewGroupValid()}
-                >
+                <button className="btn btn-success gf-form-btn" type="submit" disabled={!this.isNewGroupValid()}>
                   Add group
                 </button>
               </div>
-            </div>
+            </form>
           </div>
         </SlideDown>
 
@@ -146,4 +149,16 @@ export class TeamGroupSync extends React.Component<Props, State> {
   }
 }
 
-export default hot(module)(TeamGroupSync);
+function mapStateToProps(state) {
+  return {
+    groups: getTeamGroups(state.team),
+  };
+}
+
+const mapDispatchToProps = {
+  loadTeamGroups,
+  addTeamGroup,
+  removeTeamGroup,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(TeamGroupSync);

+ 75 - 0
public/app/features/teams/TeamList.test.tsx

@@ -0,0 +1,75 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { Props, TeamList } from './TeamList';
+import { NavModel, Team } from '../../types';
+import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    navModel: {} as NavModel,
+    teams: [] as Team[],
+    loadTeams: jest.fn(),
+    deleteTeam: jest.fn(),
+    setSearchQuery: jest.fn(),
+    searchQuery: '',
+    teamsCount: 0,
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<TeamList {...props} />);
+  const instance = wrapper.instance() as TeamList;
+
+  return {
+    wrapper,
+    instance,
+  };
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const { wrapper } = setup();
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render teams table', () => {
+    const { wrapper } = setup({
+      teams: getMultipleMockTeams(5),
+      teamsCount: 5,
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe('Life cycle', () => {
+  it('should call loadTeams', () => {
+    const { instance } = setup();
+
+    instance.componentDidMount();
+
+    expect(instance.props.loadTeams).toHaveBeenCalled();
+  });
+});
+
+describe('Functions', () => {
+  describe('Delete team', () => {
+    it('should call delete team', () => {
+      const { instance } = setup();
+      instance.deleteTeam(getMockTeam());
+
+      expect(instance.props.deleteTeam).toHaveBeenCalledWith(1);
+    });
+  });
+
+  describe('on search query change', () => {
+    it('should call setSearchQuery', () => {
+      const { instance } = setup();
+      const mockEvent = { target: { value: 'test' } };
+
+      instance.onSearchQueryChange(mockEvent);
+
+      expect(instance.props.setSearchQuery).toHaveBeenCalledWith('test');
+    });
+  });
+});

+ 68 - 58
public/app/containers/Teams/TeamList.tsx → public/app/features/teams/TeamList.tsx

@@ -1,42 +1,42 @@
-import React from 'react';
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
 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, Team } from 'app/stores/TeamsStore/TeamsStore';
-import { BackendSrv } from 'app/core/services/backend_srv';
 import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
 import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
-
-interface Props {
-  nav: typeof NavStore.Type;
-  teams: typeof TeamsStore.Type;
-  backendSrv: BackendSrv;
+import { NavModel, Team } from '../../types';
+import { loadTeams, deleteTeam, setSearchQuery } from './state/actions';
+import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors';
+import { getNavModel } from 'app/core/selectors/navModel';
+
+export interface Props {
+  navModel: NavModel;
+  teams: Team[];
+  searchQuery: string;
+  teamsCount: number;
+  loadTeams: typeof loadTeams;
+  deleteTeam: typeof deleteTeam;
+  setSearchQuery: typeof setSearchQuery;
 }
 
-@inject('nav', 'teams')
-@observer
-export class TeamList extends React.Component<Props, any> {
-  constructor(props) {
-    super(props);
-
-    this.props.nav.load('cfg', 'teams');
+export class TeamList extends PureComponent<Props, any> {
+  componentDidMount() {
     this.fetchTeams();
   }
 
-  fetchTeams() {
-    this.props.teams.loadTeams();
+  async fetchTeams() {
+    await this.props.loadTeams();
   }
 
-  deleteTeam(team: Team) {
-    this.props.backendSrv.delete('/api/teams/' + team.id).then(this.fetchTeams.bind(this));
-  }
+  deleteTeam = (team: Team) => {
+    this.props.deleteTeam(team.id);
+  };
 
-  onSearchQueryChange = evt => {
-    this.props.teams.setSearchQuery(evt.target.value);
+  onSearchQueryChange = event => {
+    this.props.setSearchQuery(event.target.value);
   };
 
-  renderTeamMember(team: Team): JSX.Element {
+  renderTeam(team: Team) {
     const teamUrl = `org/teams/edit/${team.id}`;
 
     return (
@@ -62,7 +62,28 @@ export class TeamList extends React.Component<Props, any> {
     );
   }
 
-  renderTeamList(teams) {
+  renderEmptyList() {
+    return (
+      <div className="page-container page-body">
+        <EmptyListCTA
+          model={{
+            title: "You haven't created any teams yet.",
+            buttonIcon: 'fa fa-plus',
+            buttonLink: 'org/teams/new',
+            buttonTitle: ' New team',
+            proTip: 'Assign folder and dashboard permissions to teams instead of users to ease administration.',
+            proTipLink: '',
+            proTipLinkTitle: '',
+            proTipTarget: '_blank',
+          }}
+        />
+      </div>
+    );
+  }
+
+  renderTeamList() {
+    const { teams, searchQuery } = this.props;
+
     return (
       <div className="page-container page-body">
         <div className="page-action-bar">
@@ -72,7 +93,7 @@ export class TeamList extends React.Component<Props, any> {
                 type="text"
                 className="gf-form-input"
                 placeholder="Search teams"
-                value={teams.search}
+                value={searchQuery}
                 onChange={this.onSearchQueryChange}
               />
               <i className="gf-form-input-icon fa fa-search" />
@@ -97,49 +118,38 @@ export class TeamList extends React.Component<Props, any> {
                 <th style={{ width: '1%' }} />
               </tr>
             </thead>
-            <tbody>{teams.filteredTeams.map(team => this.renderTeamMember(team))}</tbody>
+            <tbody>{teams.map(team => this.renderTeam(team))}</tbody>
           </table>
         </div>
       </div>
     );
   }
 
-  renderEmptyList() {
-    return (
-      <div className="page-container page-body">
-        <EmptyListCTA
-          model={{
-            title: "You haven't created any teams yet.",
-            buttonIcon: 'fa fa-plus',
-            buttonLink: 'org/teams/new',
-            buttonTitle: ' New team',
-            proTip: 'Assign folder and dashboard permissions to teams instead of users to ease administration.',
-            proTipLink: '',
-            proTipLinkTitle: '',
-            proTipTarget: '_blank',
-          }}
-        />
-      </div>
-    );
-  }
-
   render() {
-    const { nav, teams } = this.props;
-    let view;
-
-    if (teams.filteredTeams.length > 0) {
-      view = this.renderTeamList(teams);
-    } else {
-      view = this.renderEmptyList();
-    }
+    const { navModel, teamsCount } = this.props;
 
     return (
       <div>
-        <PageHeader model={nav as any} />
-        {view}
+        <PageHeader model={navModel} />
+        {teamsCount > 0 ? this.renderTeamList() : this.renderEmptyList()}
       </div>
     );
   }
 }
 
-export default hot(module)(TeamList);
+function mapStateToProps(state) {
+  return {
+    navModel: getNavModel(state.navIndex, 'teams'),
+    teams: getTeams(state.teams),
+    searchQuery: getSearchQuery(state.teams),
+    teamsCount: getTeamsCount(state.teams),
+  };
+}
+
+const mapDispatchToProps = {
+  loadTeams,
+  deleteTeam,
+  setSearchQuery,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TeamList));

+ 79 - 0
public/app/features/teams/TeamMembers.test.tsx

@@ -0,0 +1,79 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { TeamMembers, Props } from './TeamMembers';
+import { TeamMember } from '../../types';
+import { getMockTeamMember, getMockTeamMembers } from './__mocks__/teamMocks';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    members: [] as TeamMember[],
+    searchMemberQuery: '',
+    setSearchMemberQuery: jest.fn(),
+    loadTeamMembers: jest.fn(),
+    addTeamMember: jest.fn(),
+    removeTeamMember: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<TeamMembers {...props} />);
+  const instance = wrapper.instance() as TeamMembers;
+
+  return {
+    wrapper,
+    instance,
+  };
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const { wrapper } = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render team members', () => {
+    const { wrapper } = setup({
+      members: getMockTeamMembers(5),
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe('Functions', () => {
+  describe('on search member query change', () => {
+    it('it should call setSearchMemberQuery', () => {
+      const { instance } = setup();
+      const mockEvent = { target: { value: 'member' } };
+
+      instance.onSearchQueryChange(mockEvent);
+
+      expect(instance.props.setSearchMemberQuery).toHaveBeenCalledWith('member');
+    });
+  });
+
+  describe('on remove member', () => {
+    const { instance } = setup();
+    const mockTeamMember = getMockTeamMember();
+
+    instance.onRemoveMember(mockTeamMember);
+
+    expect(instance.props.removeTeamMember).toHaveBeenCalledWith(1);
+  });
+
+  describe('on add user to team', () => {
+    const { wrapper, instance } = setup();
+
+    wrapper.state().newTeamMember = {
+      id: 1,
+      label: '',
+      avatarUrl: '',
+      login: '',
+    };
+
+    instance.onAddUserToTeam();
+
+    expect(instance.props.addTeamMember).toHaveBeenCalledWith(1);
+  });
+});

+ 50 - 37
public/app/containers/Teams/TeamMembers.tsx → public/app/features/teams/TeamMembers.tsx

@@ -1,13 +1,19 @@
-import React from 'react';
-import { hot } from 'react-hot-loader';
-import { observer } from 'mobx-react';
-import { Team, TeamMember } from 'app/stores/TeamsStore/TeamsStore';
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
 import SlideDown from 'app/core/components/Animations/SlideDown';
 import { UserPicker, User } from 'app/core/components/Picker/UserPicker';
 import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
-
-interface Props {
-  team: Team;
+import { TeamMember } from '../../types';
+import { loadTeamMembers, addTeamMember, removeTeamMember, setSearchMemberQuery } from './state/actions';
+import { getSearchMemberQuery, getTeamMembers } from './state/selectors';
+
+export interface Props {
+  members: TeamMember[];
+  searchMemberQuery: string;
+  loadTeamMembers: typeof loadTeamMembers;
+  addTeamMember: typeof addTeamMember;
+  removeTeamMember: typeof removeTeamMember;
+  setSearchMemberQuery: typeof setSearchMemberQuery;
 }
 
 interface State {
@@ -15,28 +21,36 @@ interface State {
   newTeamMember?: User;
 }
 
-@observer
-export class TeamMembers extends React.Component<Props, State> {
+export class TeamMembers extends PureComponent<Props, State> {
   constructor(props) {
     super(props);
     this.state = { isAdding: false, newTeamMember: null };
   }
 
   componentDidMount() {
-    this.props.team.loadMembers();
+    this.props.loadTeamMembers();
   }
 
-  onSearchQueryChange = evt => {
-    this.props.team.setSearchQuery(evt.target.value);
+  onSearchQueryChange = event => {
+    this.props.setSearchMemberQuery(event.target.value);
   };
 
-  removeMember(member: TeamMember) {
-    this.props.team.removeMember(member);
+  onRemoveMember(member: TeamMember) {
+    this.props.removeTeamMember(member.userId);
   }
 
-  removeMemberConfirmed(member: TeamMember) {
-    this.props.team.removeMember(member);
-  }
+  onToggleAdding = () => {
+    this.setState({ isAdding: !this.state.isAdding });
+  };
+
+  onUserSelected = (user: User) => {
+    this.setState({ newTeamMember: user });
+  };
+
+  onAddUserToTeam = async () => {
+    this.props.addTeamMember(this.state.newTeamMember.id);
+    this.setState({ newTeamMember: null });
+  };
 
   renderMember(member: TeamMember) {
     return (
@@ -47,31 +61,16 @@ export class TeamMembers extends React.Component<Props, State> {
         <td>{member.login}</td>
         <td>{member.email}</td>
         <td className="text-right">
-          <DeleteButton onConfirmDelete={() => this.removeMember(member)} />
+          <DeleteButton onConfirmDelete={() => this.onRemoveMember(member)} />
         </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.filteredMembers;
+    const { searchMemberQuery, members } = this.props;
     const newTeamMemberValue = newTeamMember && newTeamMember.id.toString();
-    const { team } = this.props;
 
     return (
       <div>
@@ -82,7 +81,7 @@ export class TeamMembers extends React.Component<Props, State> {
                 type="text"
                 className="gf-form-input"
                 placeholder="Search members"
-                value={team.search}
+                value={searchMemberQuery}
                 onChange={this.onSearchQueryChange}
               />
               <i className="gf-form-input-icon fa fa-search" />
@@ -124,7 +123,7 @@ export class TeamMembers extends React.Component<Props, State> {
                 <th style={{ width: '1%' }} />
               </tr>
             </thead>
-            <tbody>{members.map(member => this.renderMember(member))}</tbody>
+            <tbody>{members && members.map(member => this.renderMember(member))}</tbody>
           </table>
         </div>
       </div>
@@ -132,4 +131,18 @@ export class TeamMembers extends React.Component<Props, State> {
   }
 }
 
-export default hot(module)(TeamMembers);
+function mapStateToProps(state) {
+  return {
+    members: getTeamMembers(state.team),
+    searchMemberQuery: getSearchMemberQuery(state.team),
+  };
+}
+
+const mapDispatchToProps = {
+  loadTeamMembers,
+  addTeamMember,
+  removeTeamMember,
+  setSearchMemberQuery,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(TeamMembers);

+ 63 - 0
public/app/features/teams/TeamPages.test.tsx

@@ -0,0 +1,63 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { TeamPages, Props } from './TeamPages';
+import { NavModel, Team } from '../../types';
+import { getMockTeam } from './__mocks__/teamMocks';
+
+jest.mock('app/core/config', () => ({
+  buildInfo: { isEnterprise: true },
+}));
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    navModel: {} as NavModel,
+    teamId: 1,
+    loadTeam: jest.fn(),
+    pageName: 'members',
+    team: {} as Team,
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<TeamPages {...props} />);
+  const instance = wrapper.instance();
+
+  return {
+    wrapper,
+    instance,
+  };
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const { wrapper } = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render member page if team not empty', () => {
+    const { wrapper } = setup({
+      team: getMockTeam(),
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render settings page', () => {
+    const { wrapper } = setup({
+      team: getMockTeam(),
+      pageName: 'settings',
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render group sync page', () => {
+    const { wrapper } = setup({
+      team: getMockTeam(),
+      pageName: 'groupsync',
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 105 - 0
public/app/features/teams/TeamPages.tsx

@@ -0,0 +1,105 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import _ from 'lodash';
+import { hot } from 'react-hot-loader';
+import config from 'app/core/config';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import TeamMembers from './TeamMembers';
+import TeamSettings from './TeamSettings';
+import TeamGroupSync from './TeamGroupSync';
+import { NavModel, Team } from '../../types';
+import { loadTeam } from './state/actions';
+import { getTeam } from './state/selectors';
+import { getNavModel } from '../../core/selectors/navModel';
+import { getRouteParamsId, getRouteParamsPage } from '../../core/selectors/location';
+
+export interface Props {
+  team: Team;
+  loadTeam: typeof loadTeam;
+  teamId: number;
+  pageName: string;
+  navModel: NavModel;
+}
+
+interface State {
+  isSyncEnabled: boolean;
+}
+
+enum PageTypes {
+  Members = 'members',
+  Settings = 'settings',
+  GroupSync = 'groupsync',
+}
+
+export class TeamPages extends PureComponent<Props, State> {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isSyncEnabled: config.buildInfo.isEnterprise,
+    };
+  }
+
+  componentDidMount() {
+    this.fetchTeam();
+  }
+
+  async fetchTeam() {
+    const { loadTeam, teamId } = this.props;
+
+    await loadTeam(teamId);
+  }
+
+  getCurrentPage() {
+    const pages = ['members', 'settings', 'groupsync'];
+    const currentPage = this.props.pageName;
+    return _.includes(pages, currentPage) ? currentPage : pages[0];
+  }
+
+  renderPage() {
+    const { isSyncEnabled } = this.state;
+    const currentPage = this.getCurrentPage();
+
+    switch (currentPage) {
+      case PageTypes.Members:
+        return <TeamMembers />;
+
+      case PageTypes.Settings:
+        return <TeamSettings />;
+
+      case PageTypes.GroupSync:
+        return isSyncEnabled && <TeamGroupSync />;
+    }
+
+    return null;
+  }
+
+  render() {
+    const { team, navModel } = this.props;
+
+    return (
+      <div>
+        <PageHeader model={navModel} />
+        {team && Object.keys(team).length !== 0 && <div className="page-container page-body">{this.renderPage()}</div>}
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  const teamId = getRouteParamsId(state.location);
+  const pageName = getRouteParamsPage(state.location) || 'members';
+
+  return {
+    navModel: getNavModel(state.navIndex, `team-${pageName}-${teamId}`),
+    teamId: teamId,
+    pageName: pageName,
+    team: getTeam(state.team, teamId),
+  };
+}
+
+const mapDispatchToProps = {
+  loadTeam,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TeamPages));

+ 44 - 0
public/app/features/teams/TeamSettings.test.tsx

@@ -0,0 +1,44 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { Props, TeamSettings } from './TeamSettings';
+import { getMockTeam } from './__mocks__/teamMocks';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    team: getMockTeam(),
+    updateTeam: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<TeamSettings {...props} />);
+  const instance = wrapper.instance() as TeamSettings;
+
+  return {
+    wrapper,
+    instance,
+  };
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const { wrapper } = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe('Functions', () => {
+  it('should update team', () => {
+    const { instance } = setup();
+    const mockEvent = { preventDefault: jest.fn() };
+
+    instance.setState({
+      name: 'test11',
+    });
+
+    instance.onUpdate(mockEvent);
+
+    expect(instance.props.updateTeam).toHaveBeenCalledWith('test11', 'test@test.com');
+  });
+});

+ 96 - 0
public/app/features/teams/TeamSettings.tsx

@@ -0,0 +1,96 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { Label } from 'app/core/components/Forms/Forms';
+import { Team } from '../../types';
+import { updateTeam } from './state/actions';
+import { getRouteParamsId } from '../../core/selectors/location';
+import { getTeam } from './state/selectors';
+
+export interface Props {
+  team: Team;
+  updateTeam: typeof updateTeam;
+}
+
+interface State {
+  name: string;
+  email: string;
+}
+
+export class TeamSettings extends React.Component<Props, State> {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      name: props.team.name,
+      email: props.team.email,
+    };
+  }
+
+  onChangeName = event => {
+    this.setState({ name: event.target.value });
+  };
+
+  onChangeEmail = event => {
+    this.setState({ email: event.target.value });
+  };
+
+  onUpdate = event => {
+    const { name, email } = this.state;
+    event.preventDefault();
+    this.props.updateTeam(name, email);
+  };
+
+  render() {
+    const { name, email } = this.state;
+
+    return (
+      <div>
+        <h3 className="page-sub-heading">Team Settings</h3>
+        <form name="teamDetailsForm" className="gf-form-group" onSubmit={this.onUpdate}>
+          <div className="gf-form max-width-30">
+            <Label>Name</Label>
+            <input
+              type="text"
+              required
+              value={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={email}
+              placeholder="team@email.com"
+              onChange={this.onChangeEmail}
+            />
+          </div>
+
+          <div className="gf-form-button-row">
+            <button type="submit" className="btn btn-success">
+              Update
+            </button>
+          </div>
+        </form>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  const teamId = getRouteParamsId(state.location);
+
+  return {
+    team: getTeam(state.team, teamId),
+  };
+}
+
+const mapDispatchToProps = {
+  updateTeam,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(TeamSettings);

+ 59 - 0
public/app/features/teams/__mocks__/navModelMock.ts

@@ -0,0 +1,59 @@
+export const getMockNavModel = (pageName: string) => {
+  return {
+    node: {
+      active: false,
+      icon: 'gicon gicon-team',
+      id: `team-${pageName}-2`,
+      text: `${pageName}`,
+      url: 'org/teams/edit/2/members',
+      parentItem: {
+        img: '/avatar/b5695b61c91d13e7fa2fe71cfb95de9b',
+        id: 'team-2',
+        subTitle: 'Manage members & settings',
+        url: '',
+        text: 'test1',
+        breadcrumbs: [{ title: 'Teams', url: 'org/teams' }],
+        children: [
+          {
+            active: false,
+            icon: 'gicon gicon-team',
+            id: 'team-members-2',
+            text: 'Members',
+            url: 'org/teams/edit/2/members',
+          },
+          {
+            active: false,
+            icon: 'fa fa-fw fa-sliders',
+            id: 'team-settings-2',
+            text: 'Settings',
+            url: 'org/teams/edit/2/settings',
+          },
+        ],
+      },
+    },
+    main: {
+      img: '/avatar/b5695b61c91d13e7fa2fe71cfb95de9b',
+      id: 'team-2',
+      subTitle: 'Manage members & settings',
+      url: '',
+      text: 'test1',
+      breadcrumbs: [{ title: 'Teams', url: 'org/teams' }],
+      children: [
+        {
+          active: true,
+          icon: 'gicon gicon-team',
+          id: 'team-members-2',
+          text: 'Members',
+          url: 'org/teams/edit/2/members',
+        },
+        {
+          active: false,
+          icon: 'fa fa-fw fa-sliders',
+          id: 'team-settings-2',
+          text: 'Settings',
+          url: 'org/teams/edit/2/settings',
+        },
+      ],
+    },
+  };
+};

+ 65 - 0
public/app/features/teams/__mocks__/teamMocks.ts

@@ -0,0 +1,65 @@
+import { Team, TeamGroup, TeamMember } from '../../../types';
+
+export const getMultipleMockTeams = (numberOfTeams: number): Team[] => {
+  const teams: Team[] = [];
+  for (let i = 1; i <= numberOfTeams; i++) {
+    teams.push({
+      id: i,
+      name: `test-${i}`,
+      avatarUrl: 'some/url/',
+      email: `test-${i}@test.com`,
+      memberCount: i,
+    });
+  }
+
+  return teams;
+};
+
+export const getMockTeam = (): Team => {
+  return {
+    id: 1,
+    name: 'test',
+    avatarUrl: 'some/url/',
+    email: 'test@test.com',
+    memberCount: 1,
+  };
+};
+
+export const getMockTeamMembers = (amount: number): TeamMember[] => {
+  const teamMembers: TeamMember[] = [];
+
+  for (let i = 1; i <= amount; i++) {
+    teamMembers.push({
+      userId: i,
+      teamId: 1,
+      avatarUrl: 'some/url/',
+      email: 'test@test.com',
+      login: `testUser-${i}`,
+    });
+  }
+
+  return teamMembers;
+};
+
+export const getMockTeamMember = (): TeamMember => {
+  return {
+    userId: 1,
+    teamId: 1,
+    avatarUrl: 'some/url/',
+    email: 'test@test.com',
+    login: 'testUser',
+  };
+};
+
+export const getMockTeamGroups = (amount: number): TeamGroup[] => {
+  const groups: TeamGroup[] = [];
+
+  for (let i = 1; i <= amount; i++) {
+    groups.push({
+      groupId: `group-${i}`,
+      teamId: 1,
+    });
+  }
+
+  return groups;
+};

+ 281 - 0
public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap

@@ -0,0 +1,281 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div>
+  <div
+    className="page-action-bar"
+  >
+    <h3
+      className="page-sub-heading"
+    >
+      External group sync
+    </h3>
+    <class_1
+      className="page-sub-heading-icon"
+      content="Sync LDAP or OAuth groups with your Grafana teams."
+      placement="auto"
+    >
+      <i
+        className="gicon gicon-question gicon--has-hover"
+      />
+    </class_1>
+    <div
+      className="page-action-bar__spacer"
+    />
+  </div>
+  <Component
+    in={false}
+  >
+    <div
+      className="cta-form"
+    >
+      <button
+        className="cta-form__close btn btn-transparent"
+        onClick={[Function]}
+      >
+        <i
+          className="fa fa-close"
+        />
+      </button>
+      <h5>
+        Add External Group
+      </h5>
+      <form
+        className="gf-form-inline"
+        onSubmit={[Function]}
+      >
+        <div
+          className="gf-form"
+        >
+          <input
+            className="gf-form-input width-30"
+            onChange={[Function]}
+            placeholder="cn=ops,ou=groups,dc=grafana,dc=org"
+            type="text"
+            value=""
+          />
+        </div>
+        <div
+          className="gf-form"
+        >
+          <button
+            className="btn btn-success gf-form-btn"
+            disabled={true}
+            type="submit"
+          >
+            Add group
+          </button>
+        </div>
+      </form>
+    </div>
+  </Component>
+  <div
+    className="empty-list-cta"
+  >
+    <div
+      className="empty-list-cta__title"
+    >
+      There are no external groups to sync with
+    </div>
+    <button
+      className="empty-list-cta__button btn btn-xlarge btn-success"
+      onClick={[Function]}
+    >
+      <i
+        className="gicon gicon-add-team"
+      />
+      Add Group
+    </button>
+    <div
+      className="empty-list-cta__pro-tip"
+    >
+      <i
+        className="fa fa-rocket"
+      />
+       
+      Sync LDAP or OAuth groups with your Grafana teams.
+      <a
+        className="text-link empty-list-cta__pro-tip-link"
+        href="asd"
+        target="_blank"
+      >
+        Learn more
+      </a>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`Render should render groups table 1`] = `
+<div>
+  <div
+    className="page-action-bar"
+  >
+    <h3
+      className="page-sub-heading"
+    >
+      External group sync
+    </h3>
+    <class_1
+      className="page-sub-heading-icon"
+      content="Sync LDAP or OAuth groups with your Grafana teams."
+      placement="auto"
+    >
+      <i
+        className="gicon gicon-question gicon--has-hover"
+      />
+    </class_1>
+    <div
+      className="page-action-bar__spacer"
+    />
+    <button
+      className="btn btn-success pull-right"
+      onClick={[Function]}
+    >
+      <i
+        className="fa fa-plus"
+      />
+       Add group
+    </button>
+  </div>
+  <Component
+    in={false}
+  >
+    <div
+      className="cta-form"
+    >
+      <button
+        className="cta-form__close btn btn-transparent"
+        onClick={[Function]}
+      >
+        <i
+          className="fa fa-close"
+        />
+      </button>
+      <h5>
+        Add External Group
+      </h5>
+      <form
+        className="gf-form-inline"
+        onSubmit={[Function]}
+      >
+        <div
+          className="gf-form"
+        >
+          <input
+            className="gf-form-input width-30"
+            onChange={[Function]}
+            placeholder="cn=ops,ou=groups,dc=grafana,dc=org"
+            type="text"
+            value=""
+          />
+        </div>
+        <div
+          className="gf-form"
+        >
+          <button
+            className="btn btn-success gf-form-btn"
+            disabled={true}
+            type="submit"
+          >
+            Add group
+          </button>
+        </div>
+      </form>
+    </div>
+  </Component>
+  <div
+    className="admin-list-table"
+  >
+    <table
+      className="filter-table filter-table--hover form-inline"
+    >
+      <thead>
+        <tr>
+          <th>
+            External Group ID
+          </th>
+          <th
+            style={
+              Object {
+                "width": "1%",
+              }
+            }
+          />
+        </tr>
+      </thead>
+      <tbody>
+        <tr
+          key="group-1"
+        >
+          <td>
+            group-1
+          </td>
+          <td
+            style={
+              Object {
+                "width": "1%",
+              }
+            }
+          >
+            <a
+              className="btn btn-danger btn-mini"
+              onClick={[Function]}
+            >
+              <i
+                className="fa fa-remove"
+              />
+            </a>
+          </td>
+        </tr>
+        <tr
+          key="group-2"
+        >
+          <td>
+            group-2
+          </td>
+          <td
+            style={
+              Object {
+                "width": "1%",
+              }
+            }
+          >
+            <a
+              className="btn btn-danger btn-mini"
+              onClick={[Function]}
+            >
+              <i
+                className="fa fa-remove"
+              />
+            </a>
+          </td>
+        </tr>
+        <tr
+          key="group-3"
+        >
+          <td>
+            group-3
+          </td>
+          <td
+            style={
+              Object {
+                "width": "1%",
+              }
+            }
+          >
+            <a
+              className="btn btn-danger btn-mini"
+              onClick={[Function]}
+            >
+              <i
+                className="fa fa-remove"
+              />
+            </a>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</div>
+`;

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

@@ -0,0 +1,354 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <EmptyListCTA
+      model={
+        Object {
+          "buttonIcon": "fa fa-plus",
+          "buttonLink": "org/teams/new",
+          "buttonTitle": " New team",
+          "proTip": "Assign folder and dashboard permissions to teams instead of users to ease administration.",
+          "proTipLink": "",
+          "proTipLinkTitle": "",
+          "proTipTarget": "_blank",
+          "title": "You haven't created any teams yet.",
+        }
+      }
+    />
+  </div>
+</div>
+`;
+
+exports[`Render should render teams table 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <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
+            className="gf-form-input"
+            onChange={[Function]}
+            placeholder="Search teams"
+            type="text"
+            value=""
+          />
+          <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={
+                Object {
+                  "width": "1%",
+                }
+              }
+            />
+          </tr>
+        </thead>
+        <tbody>
+          <tr
+            key="1"
+          >
+            <td
+              className="width-4 text-center link-td"
+            >
+              <a
+                href="org/teams/edit/1"
+              >
+                <img
+                  className="filter-table__avatar"
+                  src="some/url/"
+                />
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/1"
+              >
+                test-1
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/1"
+              >
+                test-1@test.com
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/1"
+              >
+                1
+              </a>
+            </td>
+            <td
+              className="text-right"
+            >
+              <DeleteButton
+                onConfirmDelete={[Function]}
+              />
+            </td>
+          </tr>
+          <tr
+            key="2"
+          >
+            <td
+              className="width-4 text-center link-td"
+            >
+              <a
+                href="org/teams/edit/2"
+              >
+                <img
+                  className="filter-table__avatar"
+                  src="some/url/"
+                />
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/2"
+              >
+                test-2
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/2"
+              >
+                test-2@test.com
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/2"
+              >
+                2
+              </a>
+            </td>
+            <td
+              className="text-right"
+            >
+              <DeleteButton
+                onConfirmDelete={[Function]}
+              />
+            </td>
+          </tr>
+          <tr
+            key="3"
+          >
+            <td
+              className="width-4 text-center link-td"
+            >
+              <a
+                href="org/teams/edit/3"
+              >
+                <img
+                  className="filter-table__avatar"
+                  src="some/url/"
+                />
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/3"
+              >
+                test-3
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/3"
+              >
+                test-3@test.com
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/3"
+              >
+                3
+              </a>
+            </td>
+            <td
+              className="text-right"
+            >
+              <DeleteButton
+                onConfirmDelete={[Function]}
+              />
+            </td>
+          </tr>
+          <tr
+            key="4"
+          >
+            <td
+              className="width-4 text-center link-td"
+            >
+              <a
+                href="org/teams/edit/4"
+              >
+                <img
+                  className="filter-table__avatar"
+                  src="some/url/"
+                />
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/4"
+              >
+                test-4
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/4"
+              >
+                test-4@test.com
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/4"
+              >
+                4
+              </a>
+            </td>
+            <td
+              className="text-right"
+            >
+              <DeleteButton
+                onConfirmDelete={[Function]}
+              />
+            </td>
+          </tr>
+          <tr
+            key="5"
+          >
+            <td
+              className="width-4 text-center link-td"
+            >
+              <a
+                href="org/teams/edit/5"
+              >
+                <img
+                  className="filter-table__avatar"
+                  src="some/url/"
+                />
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/5"
+              >
+                test-5
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/5"
+              >
+                test-5@test.com
+              </a>
+            </td>
+            <td
+              className="link-td"
+            >
+              <a
+                href="org/teams/edit/5"
+              >
+                5
+              </a>
+            </td>
+            <td
+              className="text-right"
+            >
+              <DeleteButton
+                onConfirmDelete={[Function]}
+              />
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+  </div>
+</div>
+`;

+ 317 - 0
public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap

@@ -0,0 +1,317 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div>
+  <div
+    className="page-action-bar"
+  >
+    <div
+      className="gf-form gf-form--grow"
+    >
+      <label
+        className="gf-form--has-input-icon gf-form--grow"
+      >
+        <input
+          className="gf-form-input"
+          onChange={[Function]}
+          placeholder="Search members"
+          type="text"
+          value=""
+        />
+        <i
+          className="gf-form-input-icon fa fa-search"
+        />
+      </label>
+    </div>
+    <div
+      className="page-action-bar__spacer"
+    />
+    <button
+      className="btn btn-success pull-right"
+      disabled={false}
+      onClick={[Function]}
+    >
+      <i
+        className="fa fa-plus"
+      />
+       Add a member
+    </button>
+  </div>
+  <Component
+    in={false}
+  >
+    <div
+      className="cta-form"
+    >
+      <button
+        className="cta-form__close btn btn-transparent"
+        onClick={[Function]}
+      >
+        <i
+          className="fa fa-close"
+        />
+      </button>
+      <h5>
+        Add Team Member
+      </h5>
+      <div
+        className="gf-form-inline"
+      >
+        <UserPicker
+          className="width-30"
+          onSelected={[Function]}
+          value={null}
+        />
+      </div>
+    </div>
+  </Component>
+  <div
+    className="admin-list-table"
+  >
+    <table
+      className="filter-table filter-table--hover form-inline"
+    >
+      <thead>
+        <tr>
+          <th />
+          <th>
+            Name
+          </th>
+          <th>
+            Email
+          </th>
+          <th
+            style={
+              Object {
+                "width": "1%",
+              }
+            }
+          />
+        </tr>
+      </thead>
+      <tbody />
+    </table>
+  </div>
+</div>
+`;
+
+exports[`Render should render team members 1`] = `
+<div>
+  <div
+    className="page-action-bar"
+  >
+    <div
+      className="gf-form gf-form--grow"
+    >
+      <label
+        className="gf-form--has-input-icon gf-form--grow"
+      >
+        <input
+          className="gf-form-input"
+          onChange={[Function]}
+          placeholder="Search members"
+          type="text"
+          value=""
+        />
+        <i
+          className="gf-form-input-icon fa fa-search"
+        />
+      </label>
+    </div>
+    <div
+      className="page-action-bar__spacer"
+    />
+    <button
+      className="btn btn-success pull-right"
+      disabled={false}
+      onClick={[Function]}
+    >
+      <i
+        className="fa fa-plus"
+      />
+       Add a member
+    </button>
+  </div>
+  <Component
+    in={false}
+  >
+    <div
+      className="cta-form"
+    >
+      <button
+        className="cta-form__close btn btn-transparent"
+        onClick={[Function]}
+      >
+        <i
+          className="fa fa-close"
+        />
+      </button>
+      <h5>
+        Add Team Member
+      </h5>
+      <div
+        className="gf-form-inline"
+      >
+        <UserPicker
+          className="width-30"
+          onSelected={[Function]}
+          value={null}
+        />
+      </div>
+    </div>
+  </Component>
+  <div
+    className="admin-list-table"
+  >
+    <table
+      className="filter-table filter-table--hover form-inline"
+    >
+      <thead>
+        <tr>
+          <th />
+          <th>
+            Name
+          </th>
+          <th>
+            Email
+          </th>
+          <th
+            style={
+              Object {
+                "width": "1%",
+              }
+            }
+          />
+        </tr>
+      </thead>
+      <tbody>
+        <tr
+          key="1"
+        >
+          <td
+            className="width-4 text-center"
+          >
+            <img
+              className="filter-table__avatar"
+              src="some/url/"
+            />
+          </td>
+          <td>
+            testUser-1
+          </td>
+          <td>
+            test@test.com
+          </td>
+          <td
+            className="text-right"
+          >
+            <DeleteButton
+              onConfirmDelete={[Function]}
+            />
+          </td>
+        </tr>
+        <tr
+          key="2"
+        >
+          <td
+            className="width-4 text-center"
+          >
+            <img
+              className="filter-table__avatar"
+              src="some/url/"
+            />
+          </td>
+          <td>
+            testUser-2
+          </td>
+          <td>
+            test@test.com
+          </td>
+          <td
+            className="text-right"
+          >
+            <DeleteButton
+              onConfirmDelete={[Function]}
+            />
+          </td>
+        </tr>
+        <tr
+          key="3"
+        >
+          <td
+            className="width-4 text-center"
+          >
+            <img
+              className="filter-table__avatar"
+              src="some/url/"
+            />
+          </td>
+          <td>
+            testUser-3
+          </td>
+          <td>
+            test@test.com
+          </td>
+          <td
+            className="text-right"
+          >
+            <DeleteButton
+              onConfirmDelete={[Function]}
+            />
+          </td>
+        </tr>
+        <tr
+          key="4"
+        >
+          <td
+            className="width-4 text-center"
+          >
+            <img
+              className="filter-table__avatar"
+              src="some/url/"
+            />
+          </td>
+          <td>
+            testUser-4
+          </td>
+          <td>
+            test@test.com
+          </td>
+          <td
+            className="text-right"
+          >
+            <DeleteButton
+              onConfirmDelete={[Function]}
+            />
+          </td>
+        </tr>
+        <tr
+          key="5"
+        >
+          <td
+            className="width-4 text-center"
+          >
+            <img
+              className="filter-table__avatar"
+              src="some/url/"
+            />
+          </td>
+          <td>
+            testUser-5
+          </td>
+          <td>
+            test@test.com
+          </td>
+          <td
+            className="text-right"
+          >
+            <DeleteButton
+              onConfirmDelete={[Function]}
+            />
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</div>
+`;

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

@@ -0,0 +1,48 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+</div>
+`;
+
+exports[`Render should render group sync page 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <Connect(TeamGroupSync) />
+  </div>
+</div>
+`;
+
+exports[`Render should render member page if team not empty 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <Connect(TeamMembers) />
+  </div>
+</div>
+`;
+
+exports[`Render should render settings page 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <Connect(TeamSettings) />
+  </div>
+</div>
+`;

+ 57 - 0
public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap

@@ -0,0 +1,57 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div>
+  <h3
+    className="page-sub-heading"
+  >
+    Team Settings
+  </h3>
+  <form
+    className="gf-form-group"
+    name="teamDetailsForm"
+    onSubmit={[Function]}
+  >
+    <div
+      className="gf-form max-width-30"
+    >
+      <Component>
+        Name
+      </Component>
+      <input
+        className="gf-form-input max-width-22"
+        onChange={[Function]}
+        required={true}
+        type="text"
+        value="test"
+      />
+    </div>
+    <div
+      className="gf-form max-width-30"
+    >
+      <Component
+        tooltip="This is optional and is primarily used to set the team profile avatar (via gravatar service)"
+      >
+        Email
+      </Component>
+      <input
+        className="gf-form-input max-width-22"
+        onChange={[Function]}
+        placeholder="team@email.com"
+        type="email"
+        value="test@test.com"
+      />
+    </div>
+    <div
+      className="gf-form-button-row"
+    >
+      <button
+        className="btn btn-success"
+        type="submit"
+      >
+        Update
+      </button>
+    </div>
+  </form>
+</div>
+`;

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

@@ -0,0 +1,237 @@
+import { ThunkAction } from 'redux-thunk';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+import { NavModelItem, StoreState, Team, TeamGroup, TeamMember } from 'app/types';
+import { updateNavIndex, UpdateNavIndexAction } from 'app/core/actions';
+import config from 'app/core/config';
+
+export enum ActionTypes {
+  LoadTeams = 'LOAD_TEAMS',
+  LoadTeam = 'LOAD_TEAM',
+  SetSearchQuery = 'SET_TEAM_SEARCH_QUERY',
+  SetSearchMemberQuery = 'SET_TEAM_MEMBER_SEARCH_QUERY',
+  LoadTeamMembers = 'TEAM_MEMBERS_LOADED',
+  LoadTeamGroups = 'TEAM_GROUPS_LOADED',
+}
+
+export interface LoadTeamsAction {
+  type: ActionTypes.LoadTeams;
+  payload: Team[];
+}
+
+export interface LoadTeamAction {
+  type: ActionTypes.LoadTeam;
+  payload: Team;
+}
+
+export interface LoadTeamMembersAction {
+  type: ActionTypes.LoadTeamMembers;
+  payload: TeamMember[];
+}
+
+export interface LoadTeamGroupsAction {
+  type: ActionTypes.LoadTeamGroups;
+  payload: TeamGroup[];
+}
+
+export interface SetSearchQueryAction {
+  type: ActionTypes.SetSearchQuery;
+  payload: string;
+}
+
+export interface SetSearchMemberQueryAction {
+  type: ActionTypes.SetSearchMemberQuery;
+  payload: string;
+}
+
+export type Action =
+  | LoadTeamsAction
+  | SetSearchQueryAction
+  | LoadTeamAction
+  | LoadTeamMembersAction
+  | SetSearchMemberQueryAction
+  | LoadTeamGroupsAction;
+
+type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action | UpdateNavIndexAction>;
+
+const teamsLoaded = (teams: Team[]): LoadTeamsAction => ({
+  type: ActionTypes.LoadTeams,
+  payload: teams,
+});
+
+const teamLoaded = (team: Team): LoadTeamAction => ({
+  type: ActionTypes.LoadTeam,
+  payload: team,
+});
+
+const teamMembersLoaded = (teamMembers: TeamMember[]): LoadTeamMembersAction => ({
+  type: ActionTypes.LoadTeamMembers,
+  payload: teamMembers,
+});
+
+const teamGroupsLoaded = (teamGroups: TeamGroup[]): LoadTeamGroupsAction => ({
+  type: ActionTypes.LoadTeamGroups,
+  payload: teamGroups,
+});
+
+export const setSearchMemberQuery = (searchQuery: string): SetSearchMemberQueryAction => ({
+  type: ActionTypes.SetSearchMemberQuery,
+  payload: searchQuery,
+});
+
+export const setSearchQuery = (searchQuery: string): SetSearchQueryAction => ({
+  type: ActionTypes.SetSearchQuery,
+  payload: searchQuery,
+});
+
+export function loadTeams(): ThunkResult<void> {
+  return async dispatch => {
+    const response = await getBackendSrv().get('/api/teams/search', { perpage: 1000, page: 1 });
+    dispatch(teamsLoaded(response.teams));
+  };
+}
+
+function buildNavModel(team: Team): NavModelItem {
+  const navModel = {
+    img: team.avatarUrl,
+    id: 'team-' + team.id,
+    subTitle: 'Manage members & settings',
+    url: '',
+    text: team.name,
+    breadcrumbs: [{ title: 'Teams', url: 'org/teams' }],
+    children: [
+      {
+        active: false,
+        icon: 'gicon gicon-team',
+        id: `team-members-${team.id}`,
+        text: 'Members',
+        url: `org/teams/edit/${team.id}/members`,
+      },
+      {
+        active: false,
+        icon: 'fa fa-fw fa-sliders',
+        id: `team-settings-${team.id}`,
+        text: 'Settings',
+        url: `org/teams/edit/${team.id}/settings`,
+      },
+    ],
+  };
+
+  if (config.buildInfo.isEnterprise) {
+    navModel.children.push({
+      active: false,
+      icon: 'fa fa-fw fa-refresh',
+      id: `team-groupsync-${team.id}`,
+      text: 'External group sync',
+      url: `org/teams/edit/${team.id}/groupsync`,
+    });
+  }
+
+  return navModel;
+}
+
+export function loadTeam(id: number): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv()
+      .get(`/api/teams/${id}`)
+      .then(response => {
+        dispatch(teamLoaded(response));
+        dispatch(updateNavIndex(buildNavModel(response)));
+      });
+  };
+}
+
+export function loadTeamMembers(): ThunkResult<void> {
+  return async (dispatch, getStore) => {
+    const team = getStore().team.team;
+
+    await getBackendSrv()
+      .get(`/api/teams/${team.id}/members`)
+      .then(response => {
+        dispatch(teamMembersLoaded(response));
+      });
+  };
+}
+
+export function addTeamMember(id: number): ThunkResult<void> {
+  return async (dispatch, getStore) => {
+    const team = getStore().team.team;
+
+    await getBackendSrv()
+      .post(`/api/teams/${team.id}/members`, { userId: id })
+      .then(() => {
+        dispatch(loadTeamMembers());
+      });
+  };
+}
+
+export function removeTeamMember(id: number): ThunkResult<void> {
+  return async (dispatch, getStore) => {
+    const team = getStore().team.team;
+
+    await getBackendSrv()
+      .delete(`/api/teams/${team.id}/members/${id}`)
+      .then(() => {
+        dispatch(loadTeamMembers());
+      });
+  };
+}
+
+export function updateTeam(name: string, email: string): ThunkResult<void> {
+  return async (dispatch, getStore) => {
+    const team = getStore().team.team;
+    await getBackendSrv()
+      .put(`/api/teams/${team.id}`, {
+        name,
+        email,
+      })
+      .then(() => {
+        dispatch(loadTeam(team.id));
+      });
+  };
+}
+
+export function loadTeamGroups(): ThunkResult<void> {
+  return async (dispatch, getStore) => {
+    const team = getStore().team.team;
+
+    await getBackendSrv()
+      .get(`/api/teams/${team.id}/groups`)
+      .then(response => {
+        dispatch(teamGroupsLoaded(response));
+      });
+  };
+}
+
+export function addTeamGroup(groupId: string): ThunkResult<void> {
+  return async (dispatch, getStore) => {
+    const team = getStore().team.team;
+
+    await getBackendSrv()
+      .post(`/api/teams/${team.id}/groups`, { groupId: groupId })
+      .then(() => {
+        dispatch(loadTeamGroups());
+      });
+  };
+}
+
+export function removeTeamGroup(groupId: string): ThunkResult<void> {
+  return async (dispatch, getStore) => {
+    const team = getStore().team.team;
+
+    await getBackendSrv()
+      .delete(`/api/teams/${team.id}/groups/${groupId}`)
+      .then(() => {
+        dispatch(loadTeamGroups());
+      });
+  };
+}
+
+export function deleteTeam(id: number): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv()
+      .delete(`/api/teams/${id}`)
+      .then(() => {
+        dispatch(loadTeams());
+      });
+  };
+}

+ 72 - 0
public/app/features/teams/state/reducers.test.ts

@@ -0,0 +1,72 @@
+import { Action, ActionTypes } from './actions';
+import { initialTeamsState, initialTeamState, teamReducer, teamsReducer } from './reducers';
+import { getMockTeam, getMockTeamMember } from '../__mocks__/teamMocks';
+
+describe('teams reducer', () => {
+  it('should set teams', () => {
+    const payload = [getMockTeam()];
+
+    const action: Action = {
+      type: ActionTypes.LoadTeams,
+      payload,
+    };
+
+    const result = teamsReducer(initialTeamsState, action);
+
+    expect(result.teams).toEqual(payload);
+  });
+
+  it('should set search query', () => {
+    const payload = 'test';
+
+    const action: Action = {
+      type: ActionTypes.SetSearchQuery,
+      payload,
+    };
+
+    const result = teamsReducer(initialTeamsState, action);
+
+    expect(result.searchQuery).toEqual('test');
+  });
+});
+
+describe('team reducer', () => {
+  it('should set team', () => {
+    const payload = getMockTeam();
+
+    const action: Action = {
+      type: ActionTypes.LoadTeam,
+      payload,
+    };
+
+    const result = teamReducer(initialTeamState, action);
+
+    expect(result.team).toEqual(payload);
+  });
+
+  it('should set team members', () => {
+    const mockTeamMember = getMockTeamMember();
+
+    const action: Action = {
+      type: ActionTypes.LoadTeamMembers,
+      payload: [mockTeamMember],
+    };
+
+    const result = teamReducer(initialTeamState, action);
+
+    expect(result.members).toEqual([mockTeamMember]);
+  });
+
+  it('should set member search query', () => {
+    const payload = 'member';
+
+    const action: Action = {
+      type: ActionTypes.SetSearchMemberQuery,
+      payload,
+    };
+
+    const result = teamReducer(initialTeamState, action);
+
+    expect(result.searchMemberQuery).toEqual('member');
+  });
+});

+ 44 - 0
public/app/features/teams/state/reducers.ts

@@ -0,0 +1,44 @@
+import { Team, TeamGroup, TeamMember, TeamsState, TeamState } from 'app/types';
+import { Action, ActionTypes } from './actions';
+
+export const initialTeamsState: TeamsState = { teams: [], searchQuery: '' };
+export const initialTeamState: TeamState = {
+  team: {} as Team,
+  members: [] as TeamMember[],
+  groups: [] as TeamGroup[],
+  searchMemberQuery: '',
+};
+
+export const teamsReducer = (state = initialTeamsState, action: Action): TeamsState => {
+  switch (action.type) {
+    case ActionTypes.LoadTeams:
+      return { ...state, teams: action.payload };
+
+    case ActionTypes.SetSearchQuery:
+      return { ...state, searchQuery: action.payload };
+  }
+  return state;
+};
+
+export const teamReducer = (state = initialTeamState, action: Action): TeamState => {
+  switch (action.type) {
+    case ActionTypes.LoadTeam:
+      return { ...state, team: action.payload };
+
+    case ActionTypes.LoadTeamMembers:
+      return { ...state, members: action.payload };
+
+    case ActionTypes.SetSearchMemberQuery:
+      return { ...state, searchMemberQuery: action.payload };
+
+    case ActionTypes.LoadTeamGroups:
+      return { ...state, groups: action.payload };
+  }
+
+  return state;
+};
+
+export default {
+  teams: teamsReducer,
+  team: teamReducer,
+};

+ 56 - 0
public/app/features/teams/state/selectors.test.ts

@@ -0,0 +1,56 @@
+import { getTeam, getTeamMembers, getTeams } from './selectors';
+import { getMockTeam, getMockTeamMembers, getMultipleMockTeams } from '../__mocks__/teamMocks';
+import { Team, TeamGroup, TeamsState, TeamState } from '../../../types';
+
+describe('Teams selectors', () => {
+  describe('Get teams', () => {
+    const mockTeams = getMultipleMockTeams(5);
+
+    it('should return teams if no search query', () => {
+      const mockState: TeamsState = { teams: mockTeams, searchQuery: '' };
+
+      const teams = getTeams(mockState);
+
+      expect(teams).toEqual(mockTeams);
+    });
+
+    it('Should filter teams if search query', () => {
+      const mockState: TeamsState = { teams: mockTeams, searchQuery: '5' };
+
+      const teams = getTeams(mockState);
+
+      expect(teams.length).toEqual(1);
+    });
+  });
+});
+
+describe('Team selectors', () => {
+  describe('Get team', () => {
+    const mockTeam = getMockTeam();
+
+    it('should return team if matching with location team', () => {
+      const mockState: TeamState = { team: mockTeam, searchMemberQuery: '', members: [], groups: [] };
+
+      const team = getTeam(mockState, '1');
+
+      expect(team).toEqual(mockTeam);
+    });
+  });
+
+  describe('Get members', () => {
+    const mockTeamMembers = getMockTeamMembers(5);
+
+    it('should return team members', () => {
+      const mockState: TeamState = {
+        team: {} as Team,
+        searchMemberQuery: '',
+        members: mockTeamMembers,
+        groups: [] as TeamGroup[],
+      };
+
+      const members = getTeamMembers(mockState);
+
+      expect(members).toEqual(mockTeamMembers);
+    });
+  });
+});

+ 30 - 0
public/app/features/teams/state/selectors.ts

@@ -0,0 +1,30 @@
+import { Team, TeamsState, TeamState } from 'app/types';
+
+export const getSearchQuery = (state: TeamsState) => state.searchQuery;
+export const getSearchMemberQuery = (state: TeamState) => state.searchMemberQuery;
+export const getTeamGroups = (state: TeamState) => state.groups;
+export const getTeamsCount = (state: TeamsState) => state.teams.length;
+
+export const getTeam = (state: TeamState, currentTeamId): Team | null => {
+  if (state.team.id === parseInt(currentTeamId, 10)) {
+    return state.team;
+  }
+
+  return null;
+};
+
+export const getTeams = (state: TeamsState) => {
+  const regex = RegExp(state.searchQuery, 'i');
+
+  return state.teams.filter(team => {
+    return regex.test(team.name);
+  });
+};
+
+export const getTeamMembers = (state: TeamState) => {
+  const regex = RegExp(state.searchMemberQuery, 'i');
+
+  return state.members.filter(member => {
+    return regex.test(member.login) || regex.test(member.email);
+  });
+};

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

@@ -4,9 +4,9 @@ import './ReactContainer';
 import ServerStats from 'app/features/admin/ServerStats';
 import AlertRuleList from 'app/features/alerting/AlertRuleList';
 import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions';
+import TeamPages from 'app/features/teams/TeamPages';
+import TeamList from 'app/features/teams/TeamList';
 import FolderSettings from 'app/containers/ManageDashboards/FolderSettings';
-import TeamPages from 'app/containers/Teams/TeamPages';
-import TeamList from 'app/containers/Teams/TeamList';
 
 /** @ngInject */
 export function setupAngularRoutes($routeProvider, $locationProvider) {

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

@@ -1,7 +1,6 @@
 import _ from 'lodash';
 import { types, getEnv } from 'mobx-state-tree';
 import { NavItem } from './NavItem';
-import { Team } from '../TeamsStore/TeamsStore';
 
 export const NavStore = types
   .model('NavStore', {
@@ -116,43 +115,4 @@ export const NavStore = types
 
       self.main = NavItem.create(main);
     },
-
-    initTeamPage(team: Team, tab: string, isSyncEnabled: boolean) {
-      const 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);
-    },
   }));

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

@@ -3,7 +3,6 @@ import { NavStore } from './../NavStore/NavStore';
 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({
   nav: types.optional(NavStore, {}),
@@ -17,9 +16,6 @@ export const RootStore = types.model({
     routeParams: {},
   }),
   folder: types.optional(FolderStore, {}),
-  teams: types.optional(TeamsStore, {
-    map: {},
-  }),
 });
 
 type RootStoreType = typeof RootStore.Type;

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

@@ -1,156 +0,0 @@
-import { types, getEnv, flow } from 'mobx-state-tree';
-
-export const TeamMemberModel = types.model('TeamMember', {
-  userId: types.identifier(types.number),
-  teamId: types.number,
-  avatarUrl: types.string,
-  email: types.string,
-  login: types.string,
-});
-
-type TeamMemberType = typeof TeamMemberModel.Type;
-export interface TeamMember extends TeamMemberType {}
-
-export const TeamGroupModel = types.model('TeamGroup', {
-  groupId: types.identifier(types.string),
-  teamId: types.number,
-});
-
-type TeamGroupType = typeof TeamGroupModel.Type;
-export interface TeamGroup extends TeamGroupType {}
-
-export const TeamModel = 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(TeamMemberModel), {}),
-    groups: types.optional(types.map(TeamGroupModel), {}),
-  })
-  .views(self => ({
-    get filteredMembers(this: Team) {
-      const members = this.members.values();
-      const 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 (const member of rsp) {
-        self.members.set(member.userId.toString(), TeamMemberModel.create(member));
-      }
-    }),
-
-    removeMember: flow(function* load(member: TeamMember) {
-      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 (const group of rsp) {
-        self.groups.set(group.groupId, TeamGroupModel.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,
-        TeamGroupModel.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 TeamModel.Type;
-export interface Team extends TeamType {}
-
-export const TeamsStore = types
-  .model('TeamsStore', {
-    map: types.map(TeamModel),
-    search: types.optional(types.string, ''),
-  })
-  .views(self => ({
-    get filteredTeams(this: any) {
-      const teams = this.map.values();
-      const 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 (const team of rsp.teams) {
-        self.map.set(team.id.toString(), TeamModel.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, TeamModel.create(team));
-    }),
-  }));

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

@@ -3,10 +3,12 @@ import thunk from 'redux-thunk';
 import { createLogger } from 'redux-logger';
 import sharedReducers from 'app/core/reducers';
 import alertingReducers from 'app/features/alerting/state/reducers';
+import teamsReducers from 'app/features/teams/state/reducers';
 
 const rootReducer = combineReducers({
   ...sharedReducers,
   ...alertingReducers,
+  ...teamsReducers,
 });
 
 export let store;

+ 35 - 0
public/app/types/alerting.ts

@@ -0,0 +1,35 @@
+export interface AlertRuleDTO {
+  id: number;
+  dashboardId: number;
+  dashboardUid: string;
+  dashboardSlug: string;
+  panelId: number;
+  name: string;
+  state: string;
+  newStateDate: string;
+  evalDate: string;
+  evalData?: object;
+  executionError: string;
+  url: string;
+}
+
+export interface AlertRule {
+  id: number;
+  dashboardId: number;
+  panelId: number;
+  name: string;
+  state: string;
+  stateText: string;
+  stateIcon: string;
+  stateClass: string;
+  stateAge: string;
+  url: string;
+  info?: string;
+  executionError?: string;
+  evalData?: { noData: boolean };
+}
+
+export interface AlertRulesState {
+  items: AlertRule[];
+  searchQuery: string;
+}

+ 24 - 90
public/app/types/index.ts

@@ -1,96 +1,30 @@
-//
-// Location
-//
-
-export interface LocationUpdate {
-  path?: string;
-  query?: UrlQueryMap;
-  routeParams?: UrlQueryMap;
-}
-
-export interface LocationState {
-  url: string;
-  path: string;
-  query: UrlQueryMap;
-  routeParams: UrlQueryMap;
-}
-
-export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[];
-export type UrlQueryMap = { [s: string]: UrlQueryValue };
-
-//
-// Alerting
-//
-
-export interface AlertRuleApi {
-  id: number;
-  dashboardId: number;
-  dashboardUid: string;
-  dashboardSlug: string;
-  panelId: number;
-  name: string;
-  state: string;
-  newStateDate: string;
-  evalDate: string;
-  evalData?: object;
-  executionError: string;
-  url: string;
-}
-
-export interface AlertRule {
-  id: number;
-  dashboardId: number;
-  panelId: number;
-  name: string;
-  state: string;
-  stateText: string;
-  stateIcon: string;
-  stateClass: string;
-  stateAge: string;
-  url: string;
-  info?: string;
-  executionError?: string;
-  evalData?: { noData: boolean };
-}
-
-//
-// NavModel
-//
-
-export interface NavModelItem {
-  text: string;
-  url: string;
-  subTitle?: string;
-  icon?: string;
-  img?: string;
-  id: string;
-  active?: boolean;
-  hideFromTabs?: boolean;
-  divider?: boolean;
-  children?: NavModelItem[];
-  breadcrumbs?: NavModelItem[];
-  target?: string;
-  parentItem?: NavModelItem;
-}
-
-export interface NavModel {
-  main: NavModelItem;
-  node: NavModelItem;
-}
-
-export type NavIndex = { [s: string]: NavModelItem };
-
-//
-// Store
-//
-
-export interface AlertRulesState {
-  items: AlertRule[];
-  searchQuery: string;
-}
+import { Team, TeamsState, TeamState, TeamGroup, TeamMember } from './teams';
+import { AlertRuleDTO, AlertRule, AlertRulesState } from './alerting';
+import { LocationState, LocationUpdate, UrlQueryMap, UrlQueryValue } from './location';
+import { NavModel, NavModelItem, NavIndex } from './navModel';
+
+export {
+  Team,
+  TeamsState,
+  TeamState,
+  TeamGroup,
+  TeamMember,
+  AlertRuleDTO,
+  AlertRule,
+  AlertRulesState,
+  LocationState,
+  LocationUpdate,
+  NavModel,
+  NavModelItem,
+  NavIndex,
+  UrlQueryMap,
+  UrlQueryValue,
+};
 
 export interface StoreState {
   navIndex: NavIndex;
   location: LocationState;
   alertRules: AlertRulesState;
+  teams: TeamsState;
+  team: TeamState;
 }

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

@@ -0,0 +1,15 @@
+export interface LocationUpdate {
+  path?: string;
+  query?: UrlQueryMap;
+  routeParams?: UrlQueryMap;
+}
+
+export interface LocationState {
+  url: string;
+  path: string;
+  query: UrlQueryMap;
+  routeParams: UrlQueryMap;
+}
+
+export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[];
+export type UrlQueryMap = { [s: string]: UrlQueryValue };

+ 22 - 0
public/app/types/navModel.ts

@@ -0,0 +1,22 @@
+export interface NavModelItem {
+  text: string;
+  url: string;
+  subTitle?: string;
+  icon?: string;
+  img?: string;
+  id: string;
+  active?: boolean;
+  hideFromTabs?: boolean;
+  divider?: boolean;
+  children?: NavModelItem[];
+  breadcrumbs?: Array<{ title: string; url: string }>;
+  target?: string;
+  parentItem?: NavModelItem;
+}
+
+export interface NavModel {
+  main: NavModelItem;
+  node: NavModelItem;
+}
+
+export type NavIndex = { [s: string]: NavModelItem };

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

@@ -0,0 +1,32 @@
+export interface Team {
+  id: number;
+  name: string;
+  avatarUrl: string;
+  email: string;
+  memberCount: number;
+}
+
+export interface TeamMember {
+  userId: number;
+  teamId: number;
+  avatarUrl: string;
+  email: string;
+  login: string;
+}
+
+export interface TeamGroup {
+  groupId: string;
+  teamId: number;
+}
+
+export interface TeamsState {
+  teams: Team[];
+  searchQuery: string;
+}
+
+export interface TeamState {
+  team: Team;
+  members: TeamMember[];
+  groups: TeamGroup[];
+  searchMemberQuery: string;
+}