diff --git a/public/app/core/reducers/root.test.ts b/public/app/core/reducers/root.test.ts index 86d694af84e..fd64756ac7b 100644 --- a/public/app/core/reducers/root.test.ts +++ b/public/app/core/reducers/root.test.ts @@ -28,12 +28,15 @@ describe('rootReducer', () => { reducerTester() .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; diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx index 09a144b5998..c368f2ae02f 100644 --- a/public/app/features/teams/TeamList.test.tsx +++ b/public/app/features/teams/TeamList.test.tsx @@ -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(() => { diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index b5528a19812..1896aab4b2d 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -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 { - 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([]); - 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 ( - - - {canReadTeam ? ( - - Team avatar - - ) : ( - Team avatar - )} - - - {canReadTeam ? {team.name} :
{team.name}
} - - - {canReadTeam ? ( - 0 ? undefined : 'Empty email cell'}> - {team.email} - - ) : ( -
0 ? undefined : 'Empty email cell'}> - {team.email} -
- )} - - - {canReadTeam ? ( - {team.memberCount} - ) : ( -
{team.memberCount}
- )} - - {displayRolePicker && ( - {canSeeTeamRoles && } - )} - - this.deleteTeam(team)} + return ( + + + {noTeams ? ( + - - - ); - } + ) : ( + <> +
+
+ +
- renderEmptyList() { - return ( - - ); - } + + New Team + +
- getPaginatedTeams = (teams: Team[]) => { - const offset = (this.props.searchPage - 1) * pageLimit; - return teams.slice(offset, offset + pageLimit); - }; +
+ + + + + + + + {displayRolePicker && } + + + + {teams.map((team) => ( + + ))} + +
+ NameEmailMembersRoles +
+ + + +
+
+ + )} +
+
+ ); +}; - 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 ( - <> -
-
- -
- - - New Team - -
- -
- - - - - - - - {displayRolePicker && } - - - {paginatedTeams.map((team) => this.renderTeam(team))} -
- NameEmailMembersRoles -
- - - -
-
- - ); - } - - 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 ( - - {this.renderList()} - - ); - } +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( diff --git a/public/app/features/teams/TeamListRow.tsx b/public/app/features/teams/TeamListRow.tsx new file mode 100644 index 00000000000..ad7fc7f6c9e --- /dev/null +++ b/public/app/features/teams/TeamListRow.tsx @@ -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 ( + + + {canReadTeam ? ( + + Team avatar + + ) : ( + Team avatar + )} + + + {canReadTeam ? {team.name} :
{team.name}
} + + + {canReadTeam ? ( + 0 ? undefined : 'Empty email cell'}> + {team.email} + + ) : ( +
0 ? undefined : 'Empty email cell'}> + {team.email} +
+ )} + + + {canReadTeam ? ( + {team.memberCount} + ) : ( +
{team.memberCount}
+ )} + + {displayRolePicker && {canSeeTeamRoles && }} + + onDelete(team.id)} + /> + + + ); +}; diff --git a/public/app/features/teams/state/actions.ts b/public/app/features/teams/state/actions.ts index d02e2300f4d..dfd0ba2e199 100644 --- a/public/app/features/teams/state/actions.ts +++ b/public/app/features/teams/state/actions.ts @@ -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 { - return async (dispatch) => { +export function loadTeams(initial = false): ThunkResult { + 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 { return async (dispatch) => { const response = await getBackendSrv().get(`/api/teams/${id}`, accessControlQueryParam()); @@ -31,6 +44,29 @@ export function loadTeam(id: number): ThunkResult { }; } +export function deleteTeam(id: number): ThunkResult { + 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 { + return async (dispatch) => { + dispatch(queryChanged(query)); + loadTeamsWithDebounce(dispatch); + }; +} + +export function changePage(page: number): ThunkResult { + return async (dispatch) => { + dispatch(pageChanged(page)); + dispatch(loadTeams()); + }; +} + export function loadTeamMembers(): ThunkResult { return async (dispatch, getStore) => { const team = getStore().team.team; @@ -87,15 +123,6 @@ export function removeTeamGroup(groupId: string): ThunkResult { }; } -export function deleteTeam(id: number): ThunkResult { - 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 { return async (dispatch) => { await getBackendSrv().put(`/api/teams/${member.teamId}/members/${member.userId}`, { diff --git a/public/app/features/teams/state/reducers.test.ts b/public/app/features/teams/state/reducers.test.ts index 5482f349650..ba4984bd7be 100644 --- a/public/app/features/teams/state/reducers.test.ts +++ b/public/app/features/teams/state/reducers.test.ts @@ -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() .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() .givenReducer(teamsReducer, { ...initialTeamsState }) - .whenActionIsDispatched(setSearchQuery('test')) + .whenActionIsDispatched(queryChanged('test')) .thenStateShouldEqual({ ...initialTeamsState, - searchQuery: 'test', + query: 'test', }); }); }); diff --git a/public/app/features/teams/state/reducers.ts b/public/app/features/teams/state/reducers.ts index 5c3f193b21c..58d6f45fe5c 100644 --- a/public/app/features/teams/state/reducers.ts +++ b/public/app/features/teams/state/reducers.ts @@ -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): TeamsState => { - return { ...state, hasFetched: true, teams: action.payload }; + teamsLoaded: (state, action: PayloadAction): TeamsState => { + const { totalCount, perPage, ...rest } = action.payload; + const totalPages = Math.ceil(totalCount / perPage); + return { ...state, ...rest, totalPages, perPage, hasFetched: true }; }, - setSearchQuery: (state, action: PayloadAction): TeamsState => { - return { ...state, searchQuery: action.payload, searchPage: initialTeamsState.searchPage }; + queryChanged: (state, action: PayloadAction): TeamsState => { + return { ...state, page: 1, query: action.payload }; }, - setTeamsSearchPage: (state, action: PayloadAction): TeamsState => { - return { ...state, searchPage: action.payload }; + pageChanged: (state, action: PayloadAction): 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; diff --git a/public/app/features/teams/state/selectors.test.ts b/public/app/features/teams/state/selectors.test.ts index 850943fb64b..2d86f980cff 100644 --- a/public/app/features/teams/state/selectors.test.ts +++ b/public/app/features/teams/state/selectors.test.ts @@ -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', () => { diff --git a/public/app/features/teams/state/selectors.ts b/public/app/features/teams/state/selectors.ts index a374645fd53..eb20ed2e1a3 100644 --- a/public/app/features/teams/state/selectors.ts +++ b/public/app/features/teams/state/selectors.ts @@ -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'); diff --git a/public/app/types/teams.ts b/public/app/types/teams.ts index 99739affb95..07617caaa0a 100644 --- a/public/app/types/teams.ts +++ b/public/app/types/teams.ts @@ -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; }