Преглед изворни кода

Merge remote-tracking branch 'origin/teams-page-replace-mobx' into folder-to-redux

Torkel Ödegaard пре 7 година
родитељ
комит
9caa030108
33 измењених фајлова са 2097 додато и 293 уклоњено
  1. 16 16
      pkg/models/datasource.go
  2. 0 77
      public/app/containers/Teams/TeamPages.tsx
  3. 0 69
      public/app/containers/Teams/TeamSettings.tsx
  4. 2 1
      public/app/core/actions/index.ts
  5. 11 3
      public/app/core/actions/navModel.ts
  6. 0 1
      public/app/core/components/CustomScrollbar/CustomScrollbar.tsx
  7. 16 2
      public/app/core/reducers/navModel.ts
  8. 3 0
      public/app/core/selectors/location.ts
  9. 2 1
      public/app/features/dashboard/folder_picker/folder_picker.ts
  10. 0 0
      public/app/features/teams/TeamGroupSync.test.tsx
  11. 44 25
      public/app/features/teams/TeamGroupSync.tsx
  12. 73 0
      public/app/features/teams/TeamList.test.tsx
  13. 67 58
      public/app/features/teams/TeamList.tsx
  14. 79 0
      public/app/features/teams/TeamMembers.test.tsx
  15. 50 37
      public/app/features/teams/TeamMembers.tsx
  16. 63 0
      public/app/features/teams/TeamPages.test.tsx
  17. 105 0
      public/app/features/teams/TeamPages.tsx
  18. 44 0
      public/app/features/teams/TeamSettings.test.tsx
  19. 96 0
      public/app/features/teams/TeamSettings.tsx
  20. 59 0
      public/app/features/teams/__mocks__/navModelMock.ts
  21. 52 0
      public/app/features/teams/__mocks__/teamMocks.ts
  22. 404 0
      public/app/features/teams/__snapshots__/TeamList.test.tsx.snap
  23. 317 0
      public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap
  24. 58 0
      public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap
  25. 57 0
      public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap
  26. 238 0
      public/app/features/teams/state/actions.ts
  27. 72 0
      public/app/features/teams/state/reducers.test.ts
  28. 44 0
      public/app/features/teams/state/reducers.ts
  29. 56 0
      public/app/features/teams/state/selectors.test.ts
  30. 25 0
      public/app/features/teams/state/selectors.ts
  31. 2 2
      public/app/routes/routes.ts
  32. 2 0
      public/app/stores/configureStore.ts
  33. 40 1
      public/app/types/index.ts

+ 16 - 16
pkg/models/datasource.go

@@ -59,22 +59,22 @@ type DataSource struct {
 }
 
 var knownDatasourcePlugins = map[string]bool{
-	DS_ES:                                 true,
-	DS_GRAPHITE:                           true,
-	DS_INFLUXDB:                           true,
-	DS_INFLUXDB_08:                        true,
-	DS_KAIROSDB:                           true,
-	DS_CLOUDWATCH:                         true,
-	DS_PROMETHEUS:                         true,
-	DS_OPENTSDB:                           true,
-	DS_POSTGRES:                           true,
-	DS_MYSQL:                              true,
-	DS_MSSQL:                              true,
-	"opennms":                             true,
-	"abhisant-druid-datasource":           true,
-	"dalmatinerdb-datasource":             true,
-	"gnocci":                              true,
-	"zabbix":                              true,
+	DS_ES:                       true,
+	DS_GRAPHITE:                 true,
+	DS_INFLUXDB:                 true,
+	DS_INFLUXDB_08:              true,
+	DS_KAIROSDB:                 true,
+	DS_CLOUDWATCH:               true,
+	DS_PROMETHEUS:               true,
+	DS_OPENTSDB:                 true,
+	DS_POSTGRES:                 true,
+	DS_MYSQL:                    true,
+	DS_MSSQL:                    true,
+	"opennms":                   true,
+	"abhisant-druid-datasource": true,
+	"dalmatinerdb-datasource":   true,
+	"gnocci":                    true,
+	"zabbix":                    true,
 	"alexanderzobnin-zabbix-datasource":   true,
 	"newrelic-app":                        true,
 	"grafana-datadog-datasource":          true,

+ 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 } from './navModel';
 
-export { updateLocation };
+export { updateLocation, updateNavIndex };

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

@@ -1,3 +1,9 @@
+import { NavModelItem } from '../../types';
+
+export enum ActionTypes {
+  UpdateNavIndex = 'UPDATE_NAV_INDEX',
+}
+
 export type Action = UpdateNavIndexAction;
 
 // this action is not used yet
@@ -5,9 +11,11 @@ export type Action = UpdateNavIndexAction;
 // like datasource edit, teams edit page
 
 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;

+ 2 - 1
public/app/features/dashboard/folder_picker/folder_picker.ts

@@ -131,6 +131,7 @@ export class FolderPickerCtrl {
   private loadInitialValue() {
     const resetFolder = { text: this.initialTitle, value: null };
     const rootFolder = { text: this.rootName, value: 0 };
+
     this.getOptions('').then(result => {
       let folder;
       if (this.initialFolderId) {
@@ -150,7 +151,7 @@ export class FolderPickerCtrl {
       this.folder = folder;
 
       // if this is not the same as our initial value notify parent
-      if (this.folder.id !== this.initialFolderId) {
+      if (this.folder.value !== this.initialFolderId) {
         this.onChange({ $folder: { id: this.folder.value, title: this.folder.text } });
       }
     });

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


+ 44 - 25
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,28 +20,18 @@ 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 = () => {
@@ -49,21 +43,34 @@ export class TeamGroupSync extends React.Component<Props, State> {
   };
 
   onAddGroup = () => {
-    this.props.team.addGroup(this.state.newGroupId);
+    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>
@@ -146,4 +153,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);

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

@@ -0,0 +1,73 @@
+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: '',
+  };
+
+  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),
+    });
+
+    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');
+    });
+  });
+});

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

@@ -1,42 +1,41 @@
-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 } from './state/selectors';
+import { getNavModel } from 'app/core/selectors/navModel';
+
+export interface Props {
+  navModel: NavModel;
+  teams: Team[];
+  loadTeams: typeof loadTeams;
+  deleteTeam: typeof deleteTeam;
+  setSearchQuery: typeof setSearchQuery;
+  searchQuery: string;
 }
 
-@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 +61,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 +92,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 +117,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, teams } = this.props;
 
     return (
       <div>
-        <PageHeader model={nav as any} />
-        {view}
+        <PageHeader model={navModel} />
+        {teams.length > 0 && this.renderTeamList()}
+        {teams.length === 0 && 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),
+  };
+}
+
+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',
+        },
+      ],
+    },
+  };
+};

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

@@ -0,0 +1,52 @@
+import { Team, 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',
+  };
+};

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

@@ -0,0 +1,404 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 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 />
+      </table>
+    </div>
+  </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>
+`;

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

@@ -0,0 +1,58 @@
+// 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"
+  >
+    <TeamGroupSync
+      team={
+        Object {
+          "avatarUrl": "some/url/",
+          "email": "test@test.com",
+          "id": 1,
+          "memberCount": 1,
+          "name": "test",
+        }
+      }
+    />
+  </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>
+`;

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

@@ -0,0 +1,238 @@
+import { ThunkAction } from 'redux-thunk';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+import { NavModelItem, StoreState, Team, TeamGroup, TeamMember } from '../../../types';
+import { updateNavIndex } from '../../../core/actions';
+import { UpdateNavIndexAction } from '../../../core/actions/navModel';
+import config from 'app/core/config';
+
+export enum ActionTypes {
+  LoadTeams = 'LOAD_TEAMS',
+  LoadTeam = 'LOAD_TEAM',
+  SetSearchQuery = 'SET_SEARCH_QUERY',
+  SetSearchMemberQuery = 'SET_SEARCH_MEMBER_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-settings',
+      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 '../../../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);
+    });
+  });
+});

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

@@ -0,0 +1,25 @@
+export const getSearchQuery = state => state.searchQuery;
+export const getSearchMemberQuery = state => state.searchMemberQuery;
+export const getTeamGroups = state => state.groups;
+
+export const getTeam = (state, currentTeamId) => {
+  if (state.team.id === parseInt(currentTeamId, 10)) {
+    return state.team;
+  }
+};
+
+export const getTeams = state => {
+  const regex = RegExp(state.searchQuery, 'i');
+
+  return state.teams.filter(team => {
+    return regex.test(team.name);
+  });
+};
+
+export const getTeamMembers = state => {
+  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

@@ -5,8 +5,8 @@ import ServerStats from 'app/features/admin/ServerStats';
 import AlertRuleList from 'app/features/alerting/AlertRuleList';
 import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions';
 import FolderSettingsPage from 'app/features/manage-dashboards/FolderSettingsPage';
-import TeamPages from 'app/containers/Teams/TeamPages';
-import TeamList from 'app/containers/Teams/TeamList';
+import TeamPages from 'app/features/teams/TeamPages';
+import TeamList from 'app/features/teams/TeamList';
 
 /** @ngInject */
 export function setupAngularRoutes($routeProvider, $locationProvider) {

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

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

@@ -57,6 +57,31 @@ export interface AlertRule {
   evalData?: { noData: boolean };
 }
 
+//
+// Teams
+//
+
+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;
+}
+
 //
 // NavModel
 //
@@ -72,7 +97,7 @@ export interface NavModelItem {
   hideFromTabs?: boolean;
   divider?: boolean;
   children?: NavModelItem[];
-  breadcrumbs?: NavModelItem[];
+  breadcrumbs?: Array<{ title: string; url: string }>;
   target?: string;
   parentItem?: NavModelItem;
 }
@@ -93,8 +118,22 @@ export interface AlertRulesState {
   searchQuery: string;
 }
 
+export interface TeamsState {
+  teams: Team[];
+  searchQuery: string;
+}
+
+export interface TeamState {
+  team: Team;
+  members: TeamMember[];
+  groups: TeamGroup[];
+  searchMemberQuery: string;
+}
+
 export interface StoreState {
   navIndex: NavIndex;
   location: LocationState;
   alertRules: AlertRulesState;
+  teams: TeamsState;
+  team: TeamState;
 }