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
This commit is contained in:
Peter Holmberg 2018-09-11 14:14:03 +02:00 committed by Torkel Ödegaard
parent 1ce9001141
commit 9f73f13091
42 changed files with 2493 additions and 595 deletions

View File

@ -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);

View File

@ -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);

View File

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

View File

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

View File

@ -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,

View File

@ -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;
};

View File

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

View File

@ -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,
});

View File

@ -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,

View File

@ -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);

View File

@ -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');
});
});

View File

@ -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';
import { TeamGroup } from '../../types';
import { addTeamGroup, loadTeamGroups, removeTeamGroup } from './state/actions';
import { getTeamGroups } from './state/selectors';
interface Props {
team: Team;
export interface Props {
groups: TeamGroup[];
loadTeamGroups: typeof loadTeamGroups;
addTeamGroup: typeof addTeamGroup;
removeTeamGroup: typeof removeTeamGroup;
}
interface State {
@ -16,15 +20,40 @@ 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();
}
async fetchTeamGroups() {
await this.props.loadTeamGroups();
}
onToggleAdding = () => {
this.setState({ isAdding: !this.state.isAdding });
};
onNewGroupIdChanged = event => {
this.setState({ newGroupId: event.target.value });
};
onAddGroup = event => {
event.preventDefault();
this.props.addTeamGroup(this.state.newGroupId);
this.setState({ isAdding: false, newGroupId: '' });
};
onRemoveGroup = (group: TeamGroup) => {
this.props.removeTeamGroup(group.groupId);
};
isNewGroupValid() {
return this.state.newGroupId.length > 1;
}
renderGroup(group: TeamGroup) {
@ -40,30 +69,9 @@ export class TeamGroupSync extends React.Component<Props, State> {
);
}
onToggleAdding = () => {
this.setState({ isAdding: !this.state.isAdding });
};
onNewGroupIdChanged = evt => {
this.setState({ newGroupId: evt.target.value });
};
onAddGroup = () => {
this.props.team.addGroup(this.state.newGroupId);
this.setState({ isAdding: false, newGroupId: '' });
};
onRemoveGroup = (group: TeamGroup) => {
this.props.team.removeGroup(group.groupId);
};
isNewGroupValid() {
return this.state.newGroupId.length > 1;
}
render() {
const { isAdding, newGroupId } = this.state;
const groups = this.props.team.groups.values();
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);

View File

@ -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');
});
});
});

View File

@ -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';
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';
interface Props {
nav: typeof NavStore.Type;
teams: typeof TeamsStore.Type;
backendSrv: BackendSrv;
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));
}
onSearchQueryChange = evt => {
this.props.teams.setSearchQuery(evt.target.value);
deleteTeam = (team: Team) => {
this.props.deleteTeam(team.id);
};
renderTeamMember(team: Team): JSX.Element {
onSearchQueryChange = event => {
this.props.setSearchQuery(event.target.value);
};
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));

View File

@ -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);
});
});

View File

@ -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';
import { TeamMember } from '../../types';
import { loadTeamMembers, addTeamMember, removeTeamMember, setSearchMemberQuery } from './state/actions';
import { getSearchMemberQuery, getTeamMembers } from './state/selectors';
interface Props {
team: Team;
export interface Props {
members: TeamMember[];
searchMemberQuery: string;
loadTeamMembers: typeof loadTeamMembers;
addTeamMember: typeof addTeamMember;
removeTeamMember: typeof removeTeamMember;
setSearchMemberQuery: typeof setSearchMemberQuery;
}
interface State {
@ -15,42 +21,22 @@ 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);
}
removeMemberConfirmed(member: TeamMember) {
this.props.team.removeMember(member);
}
renderMember(member: TeamMember) {
return (
<tr key={member.userId}>
<td className="width-4 text-center">
<img className="filter-table__avatar" src={member.avatarUrl} />
</td>
<td>{member.login}</td>
<td>{member.email}</td>
<td className="text-right">
<DeleteButton onConfirmDelete={() => this.removeMember(member)} />
</td>
</tr>
);
onRemoveMember(member: TeamMember) {
this.props.removeTeamMember(member.userId);
}
onToggleAdding = () => {
@ -62,16 +48,29 @@ export class TeamMembers extends React.Component<Props, State> {
};
onAddUserToTeam = async () => {
await this.props.team.addMember(this.state.newTeamMember.id);
await this.props.team.loadMembers();
this.props.addTeamMember(this.state.newTeamMember.id);
this.setState({ newTeamMember: null });
};
renderMember(member: TeamMember) {
return (
<tr key={member.userId}>
<td className="width-4 text-center">
<img className="filter-table__avatar" src={member.avatarUrl} />
</td>
<td>{member.login}</td>
<td>{member.email}</td>
<td className="text-right">
<DeleteButton onConfirmDelete={() => this.onRemoveMember(member)} />
</td>
</tr>
);
}
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);

View File

@ -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();
});
});

View File

@ -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));

View File

@ -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');
});
});

View File

@ -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);

View File

@ -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',
},
],
},
};
};

View File

@ -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;
};

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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());
});
};
}

View File

@ -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');
});
});

View File

@ -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,
};

View File

@ -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);
});
});
});

View File

@ -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);
});
};

View File

@ -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) {

View File

@ -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);
},
}));

View File

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

View File

@ -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));
}),
}));

View File

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

View File

@ -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;
}

View File

@ -1,96 +1,30 @@
//
// Location
//
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 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;
}
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;
}

View File

@ -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 };

View File

@ -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
public/app/types/teams.ts Normal file
View File

@ -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;
}