Refactor team pages to react & design change (#12574)

* Rewriting team pages in react

* teams to react progress

* teams: getting team by id returns same DTO as search, needed for AvatarUrl

* teams: progress on new team pages

* fix: team test

* listing team members and removing team members now works

* teams: team member page now works

* ux: fixed adding team member issue

* refactoring TeamPicker to conform to react coding styles better

* teams: very close to being done with team page rewrite

* minor style tweak

* ux: polish to team pages

* feature: team pages in react & everything working

* fix: removed flickering when changing tabs by always rendering PageHeader
This commit is contained in:
Torkel Ödegaard
2018-07-11 11:23:07 -07:00
committed by GitHub
parent 18a8290c65
commit c03764ff8a
47 changed files with 1015 additions and 757 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import appEvents from 'app/core/app_events';
import Drop from 'tether-drop'; import Drop from 'tether-drop';
import { createStore } from 'app/stores/store'; import { createStore } from 'app/stores/store';
import colors from 'app/core/utils/colors'; import colors from 'app/core/utils/colors';
import { BackendSrv } from 'app/core/services/backend_srv'; import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
export class GrafanaCtrl { export class GrafanaCtrl {
@ -24,6 +24,8 @@ export class GrafanaCtrl {
backendSrv: BackendSrv, backendSrv: BackendSrv,
datasourceSrv: DatasourceSrv datasourceSrv: DatasourceSrv
) { ) {
// sets singleston instances for angular services so react components can access them
setBackendSrv(backendSrv);
createStore({ backendSrv, datasourceSrv }); createStore({ backendSrv, datasourceSrv });
$scope.init = function() { $scope.init = function() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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