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

This commit is contained in:
Torkel Ödegaard
2018-09-10 21:14:33 +02:00
33 changed files with 2106 additions and 302 deletions

View File

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

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

View File

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

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

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

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,39 @@ 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 = evt => {
this.setState({ newGroupId: evt.target.value });
};
onAddGroup = () => {
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 +68,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>
@@ -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);

View File

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

View File

@@ -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';
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';
interface Props {
nav: typeof NavStore.Type;
teams: typeof TeamsStore.Type;
backendSrv: BackendSrv;
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));
}
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 +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));

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

View File

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

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

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

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 '../../../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,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);
});
};

View File

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

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

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