mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Teams: Support paginating and filtering more then 1000 teams (#58761)
* TeamList: break out rows to its own component * TeamsState: Add total count * TeamList: Remove teamsCount prop * TeamList: Restructure code and use count from backend response * TeamList: calculate total pages using totalCount * TeamList: Rename to state to currentPage and the reducer to setCurrentPage * TeamList: remove wrapper functions * TeamList: rewrite as a functional component * TeamList: export components for test * TeamList: pass limit, page and query to backend * TeamList: Rename properties in state and create actions for page and query change * TeamList: Add flag to control if EmptyList banner should render
This commit is contained in:
parent
a8c48b6801
commit
9283773c12
@ -28,12 +28,15 @@ describe('rootReducer', () => {
|
||||
|
||||
reducerTester<StoreState>()
|
||||
.givenReducer(rootReducer, state)
|
||||
.whenActionIsDispatched(teamsLoaded(teams))
|
||||
.whenActionIsDispatched(teamsLoaded({ teams: teams, page: 1, noTeams: false, perPage: 30, totalCount: 1 }))
|
||||
.thenStatePredicateShouldEqual((resultingState) => {
|
||||
expect(resultingState.teams).toEqual({
|
||||
hasFetched: true,
|
||||
searchQuery: '',
|
||||
searchPage: 1,
|
||||
noTeams: false,
|
||||
perPage: 30,
|
||||
totalPages: 1,
|
||||
query: '',
|
||||
page: 1,
|
||||
teams,
|
||||
});
|
||||
return true;
|
||||
@ -47,8 +50,11 @@ describe('rootReducer', () => {
|
||||
const state: StoreState = {
|
||||
teams: {
|
||||
hasFetched: true,
|
||||
searchQuery: '',
|
||||
searchPage: 1,
|
||||
query: '',
|
||||
page: 1,
|
||||
noTeams: false,
|
||||
totalPages: 1,
|
||||
perPage: 30,
|
||||
teams,
|
||||
},
|
||||
} as StoreState;
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { mockToolkitActionCreator } from 'test/core/redux/mocks';
|
||||
|
||||
import { contextSrv, User } from 'app/core/services/context_srv';
|
||||
|
||||
@ -9,7 +8,6 @@ import { OrgRole, Team } from '../../types';
|
||||
|
||||
import { Props, TeamList } from './TeamList';
|
||||
import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks';
|
||||
import { setSearchQuery, setTeamsSearchPage } from './state/reducers';
|
||||
|
||||
jest.mock('app/core/config', () => ({
|
||||
...jest.requireActual('app/core/config'),
|
||||
@ -19,13 +17,14 @@ jest.mock('app/core/config', () => ({
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
teams: [] as Team[],
|
||||
noTeams: false,
|
||||
loadTeams: jest.fn(),
|
||||
deleteTeam: jest.fn(),
|
||||
setSearchQuery: mockToolkitActionCreator(setSearchQuery),
|
||||
setTeamsSearchPage: mockToolkitActionCreator(setTeamsSearchPage),
|
||||
searchQuery: '',
|
||||
searchPage: 1,
|
||||
teamsCount: 0,
|
||||
changePage: jest.fn(),
|
||||
changeQuery: jest.fn(),
|
||||
query: '',
|
||||
page: 1,
|
||||
totalPages: 0,
|
||||
hasFetched: false,
|
||||
editorsCanAdmin: false,
|
||||
signedInUser: {
|
||||
@ -52,7 +51,7 @@ describe('TeamList', () => {
|
||||
it('should enable the new team button', () => {
|
||||
setup({
|
||||
teams: getMultipleMockTeams(1),
|
||||
teamsCount: 1,
|
||||
totalCount: 1,
|
||||
hasFetched: true,
|
||||
editorsCanAdmin: true,
|
||||
signedInUser: {
|
||||
@ -69,7 +68,7 @@ describe('TeamList', () => {
|
||||
it('should disable the new team button', () => {
|
||||
setup({
|
||||
teams: getMultipleMockTeams(1),
|
||||
teamsCount: 1,
|
||||
totalCount: 1,
|
||||
hasFetched: true,
|
||||
editorsCanAdmin: true,
|
||||
signedInUser: {
|
||||
@ -87,7 +86,7 @@ describe('TeamList', () => {
|
||||
it('should call delete team', async () => {
|
||||
const mockDelete = jest.fn();
|
||||
const mockTeam = getMockTeam();
|
||||
setup({ deleteTeam: mockDelete, teams: [mockTeam], teamsCount: 1, hasFetched: true });
|
||||
setup({ deleteTeam: mockDelete, teams: [mockTeam], totalCount: 1, hasFetched: true });
|
||||
await userEvent.click(screen.getByRole('button', { name: `Delete team ${mockTeam.name}` }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
await waitFor(() => {
|
||||
|
@ -1,9 +1,8 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { DeleteButton, LinkButton, FilterInput, VerticalGroup, HorizontalGroup, Pagination } from '@grafana/ui';
|
||||
import { LinkButton, FilterInput, VerticalGroup, HorizontalGroup, Pagination } from '@grafana/ui';
|
||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
||||
import { fetchRoleOptions } from 'app/core/components/RolePicker/api';
|
||||
import { config } from 'app/core/config';
|
||||
import { contextSrv, User } from 'app/core/services/context_srv';
|
||||
@ -11,22 +10,22 @@ import { AccessControlAction, Role, StoreState, Team } from 'app/types';
|
||||
|
||||
import { connectWithCleanUp } from '../../core/components/connectWithCleanUp';
|
||||
|
||||
import { deleteTeam, loadTeams } from './state/actions';
|
||||
import { initialTeamsState, setSearchQuery, setTeamsSearchPage } from './state/reducers';
|
||||
import { getSearchQuery, getTeams, getTeamsCount, getTeamsSearchPage, isPermissionTeamAdmin } from './state/selectors';
|
||||
|
||||
const pageLimit = 30;
|
||||
import { TeamListRow } from './TeamListRow';
|
||||
import { deleteTeam, loadTeams, changePage, changeQuery } from './state/actions';
|
||||
import { initialTeamsState } from './state/reducers';
|
||||
import { isPermissionTeamAdmin } from './state/selectors';
|
||||
|
||||
export interface Props {
|
||||
teams: Team[];
|
||||
searchQuery: string;
|
||||
searchPage: number;
|
||||
teamsCount: number;
|
||||
page: number;
|
||||
query: string;
|
||||
noTeams: boolean;
|
||||
totalPages: number;
|
||||
hasFetched: boolean;
|
||||
loadTeams: typeof loadTeams;
|
||||
deleteTeam: typeof deleteTeam;
|
||||
setSearchQuery: typeof setSearchQuery;
|
||||
setTeamsSearchPage: typeof setTeamsSearchPage;
|
||||
changePage: typeof changePage;
|
||||
changeQuery: typeof changeQuery;
|
||||
editorsCanAdmin: boolean;
|
||||
signedInUser: User;
|
||||
}
|
||||
@ -35,199 +34,130 @@ export interface State {
|
||||
roleOptions: Role[];
|
||||
}
|
||||
|
||||
export class TeamList extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { roleOptions: [] };
|
||||
}
|
||||
export const TeamList = ({
|
||||
teams,
|
||||
page,
|
||||
query,
|
||||
noTeams,
|
||||
totalPages,
|
||||
hasFetched,
|
||||
loadTeams,
|
||||
deleteTeam,
|
||||
changeQuery,
|
||||
changePage,
|
||||
signedInUser,
|
||||
editorsCanAdmin,
|
||||
}: Props) => {
|
||||
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchTeams();
|
||||
useEffect(() => {
|
||||
loadTeams(true);
|
||||
}, [loadTeams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contextSrv.licensedAccessControlEnabled() && contextSrv.hasPermission(AccessControlAction.ActionRolesList)) {
|
||||
this.fetchRoleOptions();
|
||||
fetchRoleOptions().then((roles) => setRoleOptions(roles));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
async fetchTeams() {
|
||||
await this.props.loadTeams();
|
||||
}
|
||||
const canCreate = canCreateTeam(editorsCanAdmin);
|
||||
const displayRolePicker = shouldDisplayRolePicker();
|
||||
|
||||
async fetchRoleOptions() {
|
||||
const roleOptions = await fetchRoleOptions();
|
||||
this.setState({ roleOptions });
|
||||
}
|
||||
|
||||
deleteTeam = (team: Team) => {
|
||||
this.props.deleteTeam(team.id);
|
||||
};
|
||||
|
||||
onSearchQueryChange = (value: string) => {
|
||||
this.props.setSearchQuery(value);
|
||||
};
|
||||
|
||||
renderTeam(team: Team) {
|
||||
const { editorsCanAdmin, signedInUser } = this.props;
|
||||
const permission = team.permission;
|
||||
const teamUrl = `org/teams/edit/${team.id}`;
|
||||
const isTeamAdmin = isPermissionTeamAdmin({ permission, editorsCanAdmin, signedInUser });
|
||||
const canDelete = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsDelete, team, isTeamAdmin);
|
||||
const canReadTeam = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsRead, team, isTeamAdmin);
|
||||
const canSeeTeamRoles = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsRolesList, team, false);
|
||||
const displayRolePicker =
|
||||
contextSrv.licensedAccessControlEnabled() && contextSrv.hasPermission(AccessControlAction.ActionRolesList);
|
||||
|
||||
return (
|
||||
<tr key={team.id}>
|
||||
<td className="width-4 text-center link-td">
|
||||
{canReadTeam ? (
|
||||
<a href={teamUrl}>
|
||||
<img className="filter-table__avatar" src={team.avatarUrl} alt="Team avatar" />
|
||||
</a>
|
||||
) : (
|
||||
<img className="filter-table__avatar" src={team.avatarUrl} alt="Team avatar" />
|
||||
)}
|
||||
</td>
|
||||
<td className="link-td">
|
||||
{canReadTeam ? <a href={teamUrl}>{team.name}</a> : <div style={{ padding: '0px 8px' }}>{team.name}</div>}
|
||||
</td>
|
||||
<td className="link-td">
|
||||
{canReadTeam ? (
|
||||
<a href={teamUrl} aria-label={team.email?.length > 0 ? undefined : 'Empty email cell'}>
|
||||
{team.email}
|
||||
</a>
|
||||
) : (
|
||||
<div style={{ padding: '0px 8px' }} aria-label={team.email?.length > 0 ? undefined : 'Empty email cell'}>
|
||||
{team.email}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="link-td">
|
||||
{canReadTeam ? (
|
||||
<a href={teamUrl}>{team.memberCount}</a>
|
||||
) : (
|
||||
<div style={{ padding: '0px 8px' }}>{team.memberCount}</div>
|
||||
)}
|
||||
</td>
|
||||
{displayRolePicker && (
|
||||
<td>{canSeeTeamRoles && <TeamRolePicker teamId={team.id} roleOptions={this.state.roleOptions} />}</td>
|
||||
)}
|
||||
<td className="text-right">
|
||||
<DeleteButton
|
||||
aria-label={`Delete team ${team.name}`}
|
||||
size="sm"
|
||||
disabled={!canDelete}
|
||||
onConfirm={() => this.deleteTeam(team)}
|
||||
return (
|
||||
<Page navId="teams">
|
||||
<Page.Contents isLoading={!hasFetched}>
|
||||
{noTeams ? (
|
||||
<EmptyListCTA
|
||||
title="You haven't created any teams yet."
|
||||
buttonIcon="users-alt"
|
||||
buttonLink="org/teams/new"
|
||||
buttonTitle=" New team"
|
||||
buttonDisabled={!contextSrv.hasPermission(AccessControlAction.ActionTeamsCreate)}
|
||||
proTip="Assign folder and dashboard permissions to teams instead of users to ease administration."
|
||||
proTipLink=""
|
||||
proTipLinkTitle=""
|
||||
proTipTarget="_blank"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
) : (
|
||||
<>
|
||||
<div className="page-action-bar">
|
||||
<div className="gf-form gf-form--grow">
|
||||
<FilterInput placeholder="Search teams" value={query} onChange={changeQuery} />
|
||||
</div>
|
||||
|
||||
renderEmptyList() {
|
||||
return (
|
||||
<EmptyListCTA
|
||||
title="You haven't created any teams yet."
|
||||
buttonIcon="users-alt"
|
||||
buttonLink="org/teams/new"
|
||||
buttonTitle=" New team"
|
||||
buttonDisabled={!contextSrv.hasPermission(AccessControlAction.ActionTeamsCreate)}
|
||||
proTip="Assign folder and dashboard permissions to teams instead of users to ease administration."
|
||||
proTipLink=""
|
||||
proTipLinkTitle=""
|
||||
proTipTarget="_blank"
|
||||
/>
|
||||
);
|
||||
}
|
||||
<LinkButton href={canCreate ? 'org/teams/new' : '#'} disabled={!canCreate}>
|
||||
New Team
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
getPaginatedTeams = (teams: Team[]) => {
|
||||
const offset = (this.props.searchPage - 1) * pageLimit;
|
||||
return teams.slice(offset, offset + pageLimit);
|
||||
};
|
||||
<div className="admin-list-table">
|
||||
<VerticalGroup spacing="md">
|
||||
<table className="filter-table filter-table--hover form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Members</th>
|
||||
{displayRolePicker && <th>Roles</th>}
|
||||
<th style={{ width: '1%' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{teams.map((team) => (
|
||||
<TeamListRow
|
||||
key={team.id}
|
||||
team={team}
|
||||
roleOptions={roleOptions}
|
||||
displayRolePicker={displayRolePicker}
|
||||
isTeamAdmin={isPermissionTeamAdmin({
|
||||
permission: team.permission,
|
||||
editorsCanAdmin,
|
||||
signedInUser,
|
||||
})}
|
||||
onDelete={deleteTeam}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Pagination
|
||||
hideWhenSinglePage
|
||||
currentPage={page}
|
||||
numberOfPages={totalPages}
|
||||
onNavigate={changePage}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
renderTeamList() {
|
||||
const { teams, searchQuery, editorsCanAdmin, searchPage, setTeamsSearchPage } = this.props;
|
||||
const teamAdmin = contextSrv.hasRole('Admin') || (editorsCanAdmin && contextSrv.hasRole('Editor'));
|
||||
const canCreate = contextSrv.hasAccess(AccessControlAction.ActionTeamsCreate, teamAdmin);
|
||||
const displayRolePicker =
|
||||
contextSrv.licensedAccessControlEnabled() &&
|
||||
contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList) &&
|
||||
contextSrv.hasPermission(AccessControlAction.ActionRolesList);
|
||||
const newTeamHref = canCreate ? 'org/teams/new' : '#';
|
||||
const paginatedTeams = this.getPaginatedTeams(teams);
|
||||
const totalPages = Math.ceil(teams.length / pageLimit);
|
||||
function canCreateTeam(editorsCanAdmin: boolean): boolean {
|
||||
const teamAdmin = contextSrv.hasRole('Admin') || (editorsCanAdmin && contextSrv.hasRole('Editor'));
|
||||
return contextSrv.hasAccess(AccessControlAction.ActionTeamsCreate, teamAdmin);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-action-bar">
|
||||
<div className="gf-form gf-form--grow">
|
||||
<FilterInput placeholder="Search teams" value={searchQuery} onChange={this.onSearchQueryChange} />
|
||||
</div>
|
||||
|
||||
<LinkButton href={newTeamHref} disabled={!canCreate}>
|
||||
New Team
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<div className="admin-list-table">
|
||||
<VerticalGroup spacing="md">
|
||||
<table className="filter-table filter-table--hover form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Members</th>
|
||||
{displayRolePicker && <th>Roles</th>}
|
||||
<th style={{ width: '1%' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{paginatedTeams.map((team) => this.renderTeam(team))}</tbody>
|
||||
</table>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Pagination
|
||||
onNavigate={setTeamsSearchPage}
|
||||
currentPage={searchPage}
|
||||
numberOfPages={totalPages}
|
||||
hideWhenSinglePage={true}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const { teamsCount, hasFetched } = this.props;
|
||||
|
||||
if (!hasFetched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (teamsCount > 0) {
|
||||
return this.renderTeamList();
|
||||
} else {
|
||||
return this.renderEmptyList();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { hasFetched } = this.props;
|
||||
|
||||
return (
|
||||
<Page navId="teams">
|
||||
<Page.Contents isLoading={!hasFetched}>{this.renderList()}</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
function shouldDisplayRolePicker(): boolean {
|
||||
return (
|
||||
contextSrv.licensedAccessControlEnabled() &&
|
||||
contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList) &&
|
||||
contextSrv.hasPermission(AccessControlAction.ActionRolesList)
|
||||
);
|
||||
}
|
||||
|
||||
function mapStateToProps(state: StoreState) {
|
||||
return {
|
||||
teams: getTeams(state.teams),
|
||||
searchQuery: getSearchQuery(state.teams),
|
||||
searchPage: getTeamsSearchPage(state.teams),
|
||||
teamsCount: getTeamsCount(state.teams),
|
||||
teams: state.teams.teams,
|
||||
page: state.teams.page,
|
||||
query: state.teams.query,
|
||||
perPage: state.teams.perPage,
|
||||
noTeams: state.teams.noTeams,
|
||||
totalPages: state.teams.totalPages,
|
||||
hasFetched: state.teams.hasFetched,
|
||||
editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
|
||||
signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
|
||||
@ -237,8 +167,8 @@ function mapStateToProps(state: StoreState) {
|
||||
const mapDispatchToProps = {
|
||||
loadTeams,
|
||||
deleteTeam,
|
||||
setSearchQuery,
|
||||
setTeamsSearchPage,
|
||||
changePage,
|
||||
changeQuery,
|
||||
};
|
||||
|
||||
export default connectWithCleanUp(
|
||||
|
65
public/app/features/teams/TeamListRow.tsx
Normal file
65
public/app/features/teams/TeamListRow.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
|
||||
import { DeleteButton } from '@grafana/ui';
|
||||
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AccessControlAction, Role, Team } from 'app/types';
|
||||
|
||||
type Props = {
|
||||
team: Team;
|
||||
roleOptions: Role[];
|
||||
isTeamAdmin: boolean;
|
||||
displayRolePicker: boolean;
|
||||
onDelete: (id: number) => void;
|
||||
};
|
||||
|
||||
export const TeamListRow = ({ team, roleOptions, isTeamAdmin, displayRolePicker, onDelete }: Props) => {
|
||||
const teamUrl = `org/teams/edit/${team.id}`;
|
||||
const canDelete = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsDelete, team, isTeamAdmin);
|
||||
const canReadTeam = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsRead, team, isTeamAdmin);
|
||||
const canSeeTeamRoles = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsRolesList, team, false);
|
||||
|
||||
return (
|
||||
<tr key={team.id}>
|
||||
<td className="width-4 text-center link-td">
|
||||
{canReadTeam ? (
|
||||
<a href={teamUrl}>
|
||||
<img className="filter-table__avatar" src={team.avatarUrl} alt="Team avatar" />
|
||||
</a>
|
||||
) : (
|
||||
<img className="filter-table__avatar" src={team.avatarUrl} alt="Team avatar" />
|
||||
)}
|
||||
</td>
|
||||
<td className="link-td">
|
||||
{canReadTeam ? <a href={teamUrl}>{team.name}</a> : <div style={{ padding: '0px 8px' }}>{team.name}</div>}
|
||||
</td>
|
||||
<td className="link-td">
|
||||
{canReadTeam ? (
|
||||
<a href={teamUrl} aria-label={team.email?.length > 0 ? undefined : 'Empty email cell'}>
|
||||
{team.email}
|
||||
</a>
|
||||
) : (
|
||||
<div style={{ padding: '0px 8px' }} aria-label={team.email?.length > 0 ? undefined : 'Empty email cell'}>
|
||||
{team.email}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="link-td">
|
||||
{canReadTeam ? (
|
||||
<a href={teamUrl}>{team.memberCount}</a>
|
||||
) : (
|
||||
<div style={{ padding: '0px 8px' }}>{team.memberCount}</div>
|
||||
)}
|
||||
</td>
|
||||
{displayRolePicker && <td>{canSeeTeamRoles && <TeamRolePicker teamId={team.id} roleOptions={roleOptions} />}</td>}
|
||||
<td className="text-right">
|
||||
<DeleteButton
|
||||
aria-label={`Delete team ${team.name}`}
|
||||
size="sm"
|
||||
disabled={!canDelete}
|
||||
onConfirm={() => onDelete(team.id)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
@ -1,3 +1,5 @@
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { updateNavIndex } from 'app/core/actions';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
@ -5,24 +7,35 @@ import { accessControlQueryParam } from 'app/core/utils/accessControl';
|
||||
import { AccessControlAction, TeamMember, ThunkResult } from 'app/types';
|
||||
|
||||
import { buildNavModel } from './navModel';
|
||||
import { teamGroupsLoaded, teamLoaded, teamMembersLoaded, teamsLoaded } from './reducers';
|
||||
import { teamGroupsLoaded, queryChanged, pageChanged, teamLoaded, teamMembersLoaded, teamsLoaded } from './reducers';
|
||||
|
||||
export function loadTeams(): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
export function loadTeams(initial = false): ThunkResult<void> {
|
||||
return async (dispatch, getState) => {
|
||||
const { query, page, perPage } = getState().teams;
|
||||
// Early return if the user cannot list teams
|
||||
if (!contextSrv.hasPermission(AccessControlAction.ActionTeamsRead)) {
|
||||
dispatch(teamsLoaded([]));
|
||||
dispatch(teamsLoaded({ teams: [], totalCount: 0, page: 1, perPage, noTeams: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getBackendSrv().get(
|
||||
'/api/teams/search',
|
||||
accessControlQueryParam({ perpage: 1000, page: 1 })
|
||||
accessControlQueryParam({ query, page, perpage: perPage })
|
||||
);
|
||||
dispatch(teamsLoaded(response.teams));
|
||||
|
||||
// We only want to check if there is no teams on the initial request.
|
||||
// A query that returns no teams should not render the empty list banner.
|
||||
let noTeams = false;
|
||||
if (initial) {
|
||||
noTeams = response.teams.length === 0;
|
||||
}
|
||||
|
||||
dispatch(teamsLoaded({ noTeams, ...response }));
|
||||
};
|
||||
}
|
||||
|
||||
const loadTeamsWithDebounce = debounce((dispatch) => dispatch(loadTeams()), 500);
|
||||
|
||||
export function loadTeam(id: number): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
const response = await getBackendSrv().get(`/api/teams/${id}`, accessControlQueryParam());
|
||||
@ -31,6 +44,29 @@ export function loadTeam(id: number): ThunkResult<void> {
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteTeam(id: number): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
await getBackendSrv().delete(`/api/teams/${id}`);
|
||||
// Update users permissions in case they lost teams.read with the deletion
|
||||
await contextSrv.fetchUserPermissions();
|
||||
dispatch(loadTeams());
|
||||
};
|
||||
}
|
||||
|
||||
export function changeQuery(query: string): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
dispatch(queryChanged(query));
|
||||
loadTeamsWithDebounce(dispatch);
|
||||
};
|
||||
}
|
||||
|
||||
export function changePage(page: number): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
dispatch(pageChanged(page));
|
||||
dispatch(loadTeams());
|
||||
};
|
||||
}
|
||||
|
||||
export function loadTeamMembers(): ThunkResult<void> {
|
||||
return async (dispatch, getStore) => {
|
||||
const team = getStore().team.team;
|
||||
@ -87,15 +123,6 @@ export function removeTeamGroup(groupId: string): ThunkResult<void> {
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteTeam(id: number): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
await getBackendSrv().delete(`/api/teams/${id}`);
|
||||
// Update users permissions in case they lost teams.read with the deletion
|
||||
await contextSrv.fetchUserPermissions();
|
||||
dispatch(loadTeams());
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTeamMember(member: TeamMember): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
await getBackendSrv().put(`/api/teams/${member.teamId}/members/${member.userId}`, {
|
||||
|
@ -6,9 +6,9 @@ import {
|
||||
initialTeamsState,
|
||||
initialTeamState,
|
||||
setSearchMemberQuery,
|
||||
setSearchQuery,
|
||||
teamGroupsLoaded,
|
||||
teamLoaded,
|
||||
queryChanged,
|
||||
teamMembersLoaded,
|
||||
teamReducer,
|
||||
teamsLoaded,
|
||||
@ -20,11 +20,17 @@ describe('teams reducer', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<TeamsState>()
|
||||
.givenReducer(teamsReducer, { ...initialTeamsState })
|
||||
.whenActionIsDispatched(teamsLoaded([getMockTeam()]))
|
||||
.whenActionIsDispatched(
|
||||
teamsLoaded({ teams: [getMockTeam()], page: 1, perPage: 30, noTeams: false, totalCount: 100 })
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...initialTeamsState,
|
||||
hasFetched: true,
|
||||
teams: [getMockTeam()],
|
||||
noTeams: false,
|
||||
totalPages: 4,
|
||||
perPage: 30,
|
||||
page: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -33,10 +39,10 @@ describe('teams reducer', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<TeamsState>()
|
||||
.givenReducer(teamsReducer, { ...initialTeamsState })
|
||||
.whenActionIsDispatched(setSearchQuery('test'))
|
||||
.whenActionIsDispatched(queryChanged('test'))
|
||||
.thenStateShouldEqual({
|
||||
...initialTeamsState,
|
||||
searchQuery: 'test',
|
||||
query: 'test',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -2,25 +2,43 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { Team, TeamGroup, TeamMember, TeamsState, TeamState } from 'app/types';
|
||||
|
||||
export const initialTeamsState: TeamsState = { teams: [], searchQuery: '', searchPage: 1, hasFetched: false };
|
||||
export const initialTeamsState: TeamsState = {
|
||||
teams: [],
|
||||
page: 1,
|
||||
query: '',
|
||||
perPage: 30,
|
||||
totalPages: 0,
|
||||
noTeams: false,
|
||||
hasFetched: false,
|
||||
};
|
||||
|
||||
type TeamsFetched = {
|
||||
teams: Team[];
|
||||
page: number;
|
||||
perPage: number;
|
||||
noTeams: boolean;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
const teamsSlice = createSlice({
|
||||
name: 'teams',
|
||||
initialState: initialTeamsState,
|
||||
reducers: {
|
||||
teamsLoaded: (state, action: PayloadAction<Team[]>): TeamsState => {
|
||||
return { ...state, hasFetched: true, teams: action.payload };
|
||||
teamsLoaded: (state, action: PayloadAction<TeamsFetched>): TeamsState => {
|
||||
const { totalCount, perPage, ...rest } = action.payload;
|
||||
const totalPages = Math.ceil(totalCount / perPage);
|
||||
return { ...state, ...rest, totalPages, perPage, hasFetched: true };
|
||||
},
|
||||
setSearchQuery: (state, action: PayloadAction<string>): TeamsState => {
|
||||
return { ...state, searchQuery: action.payload, searchPage: initialTeamsState.searchPage };
|
||||
queryChanged: (state, action: PayloadAction<string>): TeamsState => {
|
||||
return { ...state, page: 1, query: action.payload };
|
||||
},
|
||||
setTeamsSearchPage: (state, action: PayloadAction<number>): TeamsState => {
|
||||
return { ...state, searchPage: action.payload };
|
||||
pageChanged: (state, action: PayloadAction<number>): TeamsState => {
|
||||
return { ...state, page: action.payload };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { teamsLoaded, setSearchQuery, setTeamsSearchPage } = teamsSlice.actions;
|
||||
export const { teamsLoaded, queryChanged, pageChanged } = teamsSlice.actions;
|
||||
|
||||
export const teamsReducer = teamsSlice.reducer;
|
||||
|
||||
|
@ -1,29 +1,9 @@
|
||||
import { User } from 'app/core/services/context_srv';
|
||||
|
||||
import { Team, TeamGroup, TeamsState, TeamState, OrgRole } from '../../../types';
|
||||
import { getMockTeam, getMockTeamMembers, getMultipleMockTeams } from '../__mocks__/teamMocks';
|
||||
import { Team, TeamGroup, TeamState, OrgRole } from '../../../types';
|
||||
import { getMockTeam, getMockTeamMembers } from '../__mocks__/teamMocks';
|
||||
|
||||
import { getTeam, getTeamMembers, getTeams, isSignedInUserTeamAdmin, Config } from './selectors';
|
||||
|
||||
describe('Teams selectors', () => {
|
||||
describe('Get teams', () => {
|
||||
const mockTeams = getMultipleMockTeams(5);
|
||||
|
||||
it('should return teams if no search query', () => {
|
||||
const mockState: TeamsState = { teams: mockTeams, searchQuery: '', searchPage: 1, hasFetched: false };
|
||||
|
||||
const teams = getTeams(mockState);
|
||||
expect(teams).toEqual(mockTeams);
|
||||
});
|
||||
|
||||
it('Should filter teams if search query', () => {
|
||||
const mockState: TeamsState = { teams: mockTeams, searchQuery: '5', searchPage: 1, hasFetched: false };
|
||||
|
||||
const teams = getTeams(mockState);
|
||||
expect(teams.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
import { getTeam, getTeamMembers, isSignedInUserTeamAdmin, Config } from './selectors';
|
||||
|
||||
describe('Team selectors', () => {
|
||||
describe('Get team', () => {
|
||||
|
@ -1,11 +1,8 @@
|
||||
import { User } from 'app/core/services/context_srv';
|
||||
import { Team, TeamsState, TeamState, TeamMember, OrgRole, TeamPermissionLevel } from 'app/types';
|
||||
import { Team, TeamState, TeamMember, OrgRole, TeamPermissionLevel } from 'app/types';
|
||||
|
||||
export const getSearchQuery = (state: TeamsState) => state.searchQuery;
|
||||
export const getSearchMemberQuery = (state: TeamState) => state.searchMemberQuery;
|
||||
export const getTeamGroups = (state: TeamState) => state.groups;
|
||||
export const getTeamsCount = (state: TeamsState) => state.teams.length;
|
||||
export const getTeamsSearchPage = (state: TeamsState) => state.searchPage;
|
||||
|
||||
export const getTeam = (state: TeamState, currentTeamId: any): Team | null => {
|
||||
if (state.team.id === parseInt(currentTeamId, 10)) {
|
||||
@ -15,14 +12,6 @@ export const getTeam = (state: TeamState, currentTeamId: any): Team | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getTeams = (state: TeamsState) => {
|
||||
const regex = RegExp(state.searchQuery, 'i');
|
||||
|
||||
return state.teams.filter((team) => {
|
||||
return regex.test(team.name);
|
||||
});
|
||||
};
|
||||
|
||||
export const getTeamMembers = (state: TeamState) => {
|
||||
const regex = RegExp(state.searchMemberQuery, 'i');
|
||||
|
||||
|
@ -29,8 +29,11 @@ export interface TeamGroup {
|
||||
|
||||
export interface TeamsState {
|
||||
teams: Team[];
|
||||
searchQuery: string;
|
||||
searchPage: number;
|
||||
page: number;
|
||||
query: string;
|
||||
perPage: number;
|
||||
noTeams: boolean;
|
||||
totalPages: number;
|
||||
hasFetched: boolean;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user