mirror of https://github.com/grafana/grafana
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 PageHeaderpull/12580/head
parent
18a8290c65
commit
c03764ff8a
@ -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); |
||||
@ -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); |
||||
@ -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); |
||||
@ -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); |
||||
@ -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); |
||||
@ -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> |
||||
); |
||||
}; |
||||
@ -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); |
||||
@ -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); |
||||
@ -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> |
||||
@ -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> |
||||
@ -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'); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -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); |
||||
@ -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); |
||||
@ -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)); |
||||
}), |
||||
})); |
||||
@ -1,6 +1,17 @@ |
||||
declare var global: NodeJS.Global; |
||||
|
||||
(<any>global).requestAnimationFrame = (callback) => { |
||||
(<any>global).requestAnimationFrame = callback => { |
||||
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; |
||||
}) |
||||
); |
||||
}; |
||||
|
||||
Loading…
Reference in new issue