From 867ca5b59e0ca4d5e91547c7f26fd2af33f08df7 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Mon, 17 Jan 2022 18:04:54 +0300 Subject: [PATCH] Access control: Team role picker (#43418) * Refactor: move fetching from role picker to parent component * Make built in role props optional * Initial team role picker * Add role picker to the teams list * Optimize fetching roles * Add pagination for the teams page * Fix tests * Hide roles if access control not enabled * Fix test snapshots * Refactor: use useAsync() hook * Refactor: simplify input component * Move api calls to separate file * Refactor: use useAsync() hook for user role picker * Tweak role picker input width * Fix pagination * Update test snapshots * Use loading state from useAsync() hook * Fix roles label if no roles assigned --- .../core/components/RolePicker/RolePicker.tsx | 74 +- .../components/RolePicker/RolePickerInput.tsx | 72 +- .../components/RolePicker/RolePickerMenu.tsx | 38 +- .../components/RolePicker/TeamRolePicker.tsx | 42 + .../components/RolePicker/UserRolePicker.tsx | 81 +- public/app/core/components/RolePicker/api.ts | 78 ++ public/app/core/reducers/root.test.ts | 2 + .../serviceaccounts/ServiceAccountsTable.tsx | 3 +- public/app/features/teams/TeamList.test.tsx | 4 +- public/app/features/teams/TeamList.tsx | 84 +- .../__snapshots__/TeamList.test.tsx.snap | 892 +++++++++--------- public/app/features/teams/state/reducers.ts | 9 +- .../features/teams/state/selectors.test.ts | 4 +- public/app/features/teams/state/selectors.ts | 1 + public/app/features/users/UsersTable.tsx | 3 +- public/app/types/teams.ts | 1 + 16 files changed, 806 insertions(+), 582 deletions(-) create mode 100644 public/app/core/components/RolePicker/TeamRolePicker.tsx create mode 100644 public/app/core/components/RolePicker/api.ts diff --git a/public/app/core/components/RolePicker/RolePicker.tsx b/public/app/core/components/RolePicker/RolePicker.tsx index c50288fc720..7e31e4fb1d3 100644 --- a/public/app/core/components/RolePicker/RolePicker.tsx +++ b/public/app/core/components/RolePicker/RolePicker.tsx @@ -1,61 +1,42 @@ import React, { FormEvent, useCallback, useEffect, useState } from 'react'; -import { ClickOutsideWrapper } from '@grafana/ui'; +import { ClickOutsideWrapper, HorizontalGroup, Spinner } from '@grafana/ui'; import { RolePickerMenu } from './RolePickerMenu'; import { RolePickerInput } from './RolePickerInput'; import { Role, OrgRole } from 'app/types'; export interface Props { - builtInRole: OrgRole; - getRoles: () => Promise; - getRoleOptions: () => Promise; - getBuiltinRoles: () => Promise>; - onRolesChange: (newRoles: string[]) => void; - onBuiltinRoleChange: (newRole: OrgRole) => void; + builtInRole?: OrgRole; + appliedRoles: Role[]; + roleOptions: Role[]; + builtInRoles?: Record; + isLoading?: boolean; disabled?: boolean; builtinRolesDisabled?: boolean; + showBuiltInRole?: boolean; + onRolesChange: (newRoles: string[]) => void; + onBuiltinRoleChange?: (newRole: OrgRole) => void; } export const RolePicker = ({ builtInRole, - getRoles, - getRoleOptions, - getBuiltinRoles, + appliedRoles, + roleOptions, + builtInRoles, + disabled, + isLoading, + builtinRolesDisabled, + showBuiltInRole, onRolesChange, onBuiltinRoleChange, - disabled, - builtinRolesDisabled, }: Props): JSX.Element | null => { const [isOpen, setOpen] = useState(false); - const [roleOptions, setRoleOptions] = useState([]); - const [appliedRoles, setAppliedRoles] = useState([]); - const [selectedRoles, setSelectedRoles] = useState([]); - const [selectedBuiltInRole, setSelectedBuiltInRole] = useState(builtInRole); - const [builtInRoles, setBuiltinRoles] = useState>({}); + const [selectedRoles, setSelectedRoles] = useState(appliedRoles); + const [selectedBuiltInRole, setSelectedBuiltInRole] = useState(builtInRole); const [query, setQuery] = useState(''); - const [isLoading, setIsLoading] = useState(true); useEffect(() => { - async function fetchOptions() { - try { - let options = await getRoleOptions(); - setRoleOptions(options.filter((option) => !option.name?.startsWith('managed:'))); - - const builtInRoles = await getBuiltinRoles(); - setBuiltinRoles(builtInRoles); - - const userRoles = await getRoles(); - setAppliedRoles(userRoles); - setSelectedRoles(userRoles); - } catch (e) { - // TODO handle error - console.error('Error loading options'); - } finally { - setIsLoading(false); - } - } - - fetchOptions(); - }, [getRoles, getRoleOptions, getBuiltinRoles, builtInRole]); + setSelectedRoles(appliedRoles); + }, [appliedRoles]); const onOpen = useCallback( (event: FormEvent) => { @@ -94,8 +75,10 @@ export const RolePicker = ({ setSelectedBuiltInRole(role); }; - const onUpdate = (newBuiltInRole: OrgRole, newRoles: string[]) => { - onBuiltinRoleChange(newBuiltInRole); + const onUpdate = (newRoles: string[], newBuiltInRole?: OrgRole) => { + if (onBuiltinRoleChange && newBuiltInRole) { + onBuiltinRoleChange(newBuiltInRole); + } onRolesChange(newRoles); setOpen(false); setQuery(''); @@ -109,7 +92,12 @@ export const RolePicker = ({ }; if (isLoading) { - return null; + return ( + + Loading... + + + ); } return ( @@ -124,6 +112,7 @@ export const RolePicker = ({ onClose={onClose} isFocused={isOpen} disabled={disabled} + showBuiltInRole={showBuiltInRole} /> {isOpen && ( )} diff --git a/public/app/core/components/RolePicker/RolePickerInput.tsx b/public/app/core/components/RolePicker/RolePickerInput.tsx index 3e0b172e3bd..f0b2d52c24c 100644 --- a/public/app/core/components/RolePicker/RolePickerInput.tsx +++ b/public/app/core/components/RolePicker/RolePickerInput.tsx @@ -9,8 +9,9 @@ const stopPropagation = (event: React.MouseEvent) => event.stopP interface InputProps extends HTMLProps { appliedRoles: Role[]; - builtInRole: string; + builtInRole?: string; query: string; + showBuiltInRole?: boolean; isFocused?: boolean; disabled?: boolean; onQueryChange: (query?: string) => void; @@ -24,6 +25,7 @@ export const RolePickerInput = ({ disabled, isFocused, query, + showBuiltInRole, onOpen, onClose, onQueryChange, @@ -47,26 +49,12 @@ export const RolePickerInput = ({ return !isFocused ? (
- {builtInRole} - {!!numberOfRoles && ( - - {appliedRoles?.map((role) => ( -

{role.displayName}

- ))} -
- } - > -
- {`+${numberOfRoles} role${numberOfRoles > 1 ? 's' : ''}`} -
- - )} + {showBuiltInRole && {builtInRole}} + ) : (
- {builtInRole} + {showBuiltInRole && {builtInRole}} {appliedRoles.map((role) => ( {role.displayName} ))} @@ -92,6 +80,44 @@ export const RolePickerInput = ({ RolePickerInput.displayName = 'RolePickerInput'; +interface RolesLabelProps { + appliedRoles: Role[]; + showBuiltInRole?: boolean; + numberOfRoles: number; +} + +export const RolesLabel = ({ showBuiltInRole, numberOfRoles, appliedRoles }: RolesLabelProps): JSX.Element => { + const styles = useStyles2((theme) => getTooltipStyles(theme)); + + return ( + <> + {!!numberOfRoles ? ( + + {appliedRoles?.map((role) => ( +

{role.displayName}

+ ))} +
+ } + > +
+ {`${showBuiltInRole ? '+' : ''}${numberOfRoles} role${ + numberOfRoles > 1 ? 's' : '' + }`} +
+ + ) : ( + !showBuiltInRole && ( +
+ No roles assigned +
+ ) + )} + + ); +}; + const getRolePickerInputStyles = ( theme: GrafanaTheme2, invalid: boolean, @@ -111,7 +137,7 @@ const getRolePickerInputStyles = ( `, disabled && styles.inputDisabled, css` - min-width: 520px; + width: 520px; min-height: 32px; height: auto; flex-direction: row; @@ -154,3 +180,11 @@ const getRolePickerInputStyles = ( `, }; }; + +const getTooltipStyles = (theme: GrafanaTheme2) => ({ + tooltip: css` + p { + margin-bottom: ${theme.spacing(0.5)}; + } + `, +}); diff --git a/public/app/core/components/RolePicker/RolePickerMenu.tsx b/public/app/core/components/RolePicker/RolePickerMenu.tsx index ce573bc869b..74c66a4aa0e 100644 --- a/public/app/core/components/RolePicker/RolePickerMenu.tsx +++ b/public/app/core/components/RolePicker/RolePickerMenu.tsx @@ -30,15 +30,16 @@ const fixedRoleGroupNames: Record = { }; interface RolePickerMenuProps { - builtInRole: OrgRole; - builtInRoles: BuiltInRoles; + builtInRole?: OrgRole; + builtInRoles?: BuiltInRoles; options: Role[]; appliedRoles: Role[]; showGroups?: boolean; builtinRolesDisabled?: boolean; + showBuiltInRole?: boolean; onSelect: (roles: Role[]) => void; onBuiltInRoleSelect?: (role: OrgRole) => void; - onUpdate: (newBuiltInRole: OrgRole, newRoles: string[]) => void; + onUpdate: (newRoles: string[], newBuiltInRole?: OrgRole) => void; onClear?: () => void; } @@ -49,13 +50,14 @@ export const RolePickerMenu = ({ appliedRoles, showGroups, builtinRolesDisabled, + showBuiltInRole, onSelect, onBuiltInRoleSelect, onUpdate, onClear, }: RolePickerMenuProps): JSX.Element => { const [selectedOptions, setSelectedOptions] = useState(appliedRoles); - const [selectedBuiltInRole, setSelectedBuiltInRole] = useState(builtInRole); + const [selectedBuiltInRole, setSelectedBuiltInRole] = useState(builtInRole); const [showSubMenu, setShowSubMenu] = useState(false); const [openedMenuGroup, setOpenedMenuGroup] = useState(''); const [subMenuOptions, setSubMenuOptions] = useState([]); @@ -71,7 +73,7 @@ export const RolePickerMenu = ({ }, [selectedOptions, onSelect]); useEffect(() => { - if (onBuiltInRoleSelect) { + if (onBuiltInRoleSelect && selectedBuiltInRole) { onBuiltInRoleSelect(selectedBuiltInRole); } }, [selectedBuiltInRole, onBuiltInRoleSelect]); @@ -168,24 +170,26 @@ export const RolePickerMenu = ({ const roleUID = selectedOptions[key]?.uid; selectedCustomRoles.push(roleUID); } - onUpdate(selectedBuiltInRole, selectedCustomRoles); + onUpdate(selectedCustomRoles, selectedBuiltInRole); }; return (
-
-
Built-in roles
- -
+ {showBuiltInRole && ( +
+
Built-in roles
+ +
+ )} {!!fixedRoles.length && (showGroups && !!optionGroups.length ? (
diff --git a/public/app/core/components/RolePicker/TeamRolePicker.tsx b/public/app/core/components/RolePicker/TeamRolePicker.tsx new file mode 100644 index 00000000000..b6937eab261 --- /dev/null +++ b/public/app/core/components/RolePicker/TeamRolePicker.tsx @@ -0,0 +1,42 @@ +import React, { FC, useState } from 'react'; +import { useAsync } from 'react-use'; +import { Role } from 'app/types'; +import { RolePicker } from './RolePicker'; +import { fetchRoleOptions, fetchTeamRoles, updateTeamRoles } from './api'; + +export interface Props { + teamId: number; + orgId?: number; + getRoleOptions?: () => Promise; + disabled?: boolean; + builtinRolesDisabled?: boolean; +} + +export const TeamRolePicker: FC = ({ teamId, orgId, getRoleOptions, disabled, builtinRolesDisabled }) => { + const [roleOptions, setRoleOptions] = useState([]); + const [appliedRoles, setAppliedRoles] = useState([]); + + const { loading } = useAsync(async () => { + try { + let options = await (getRoleOptions ? getRoleOptions() : fetchRoleOptions(orgId)); + setRoleOptions(options.filter((option) => !option.name?.startsWith('managed:'))); + + const teamRoles = await fetchTeamRoles(teamId, orgId); + setAppliedRoles(teamRoles); + } catch (e) { + // TODO handle error + console.error('Error loading options'); + } + }, [getRoleOptions, orgId, teamId]); + + return ( + updateTeamRoles(roles, teamId, orgId)} + roleOptions={roleOptions} + appliedRoles={appliedRoles} + isLoading={loading} + disabled={disabled} + builtinRolesDisabled={builtinRolesDisabled} + /> + ); +}; diff --git a/public/app/core/components/RolePicker/UserRolePicker.tsx b/public/app/core/components/RolePicker/UserRolePicker.tsx index 57400f845df..eaf04ddd484 100644 --- a/public/app/core/components/RolePicker/UserRolePicker.tsx +++ b/public/app/core/components/RolePicker/UserRolePicker.tsx @@ -1,7 +1,8 @@ -import React, { FC } from 'react'; -import { getBackendSrv } from '@grafana/runtime'; +import React, { FC, useState } from 'react'; +import { useAsync } from 'react-use'; import { Role, OrgRole } from 'app/types'; import { RolePicker } from './RolePicker'; +import { fetchBuiltinRoles, fetchRoleOptions, fetchUserRoles, updateUserRoles } from './api'; export interface Props { builtInRole: OrgRole; @@ -24,64 +25,38 @@ export const UserRolePicker: FC = ({ disabled, builtinRolesDisabled, }) => { + const [roleOptions, setRoleOptions] = useState([]); + const [appliedRoles, setAppliedRoles] = useState([]); + const [builtInRoles, setBuiltinRoles] = useState>({}); + + const { loading } = useAsync(async () => { + try { + let options = await (getRoleOptions ? getRoleOptions() : fetchRoleOptions(orgId)); + setRoleOptions(options.filter((option) => !option.name?.startsWith('managed:'))); + + const builtInRoles = await (getBuiltinRoles ? getBuiltinRoles() : fetchBuiltinRoles(orgId)); + setBuiltinRoles(builtInRoles); + + const userRoles = await fetchUserRoles(userId, orgId); + setAppliedRoles(userRoles); + } catch (e) { + // TODO handle error + console.error('Error loading options'); + } + }, [getBuiltinRoles, getRoleOptions, orgId, userId]); + return ( updateUserRoles(roles, userId, orgId)} onBuiltinRoleChange={onBuiltinRoleChange} - getRoleOptions={() => (getRoleOptions ? getRoleOptions() : fetchRoleOptions(orgId))} - getRoles={() => fetchUserRoles(userId, orgId)} - getBuiltinRoles={() => (getBuiltinRoles ? getBuiltinRoles() : fetchBuiltinRoles(orgId))} + roleOptions={roleOptions} + appliedRoles={appliedRoles} + builtInRoles={builtInRoles} + isLoading={loading} disabled={disabled} builtinRolesDisabled={builtinRolesDisabled} + showBuiltInRole /> ); }; - -export const fetchRoleOptions = async (orgId?: number, query?: string): Promise => { - let rolesUrl = '/api/access-control/roles?delegatable=true'; - if (orgId) { - rolesUrl += `&targetOrgId=${orgId}`; - } - const roles = await getBackendSrv().get(rolesUrl); - if (!roles || !roles.length) { - return []; - } - return roles; -}; - -export const fetchBuiltinRoles = (orgId?: number): Promise<{ [key: string]: Role[] }> => { - let builtinRolesUrl = '/api/access-control/builtin-roles'; - if (orgId) { - builtinRolesUrl += `?targetOrgId=${orgId}`; - } - return getBackendSrv().get(builtinRolesUrl); -}; - -export const fetchUserRoles = async (userId: number, orgId?: number): Promise => { - let userRolesUrl = `/api/access-control/users/${userId}/roles`; - if (orgId) { - userRolesUrl += `?targetOrgId=${orgId}`; - } - try { - const roles = await getBackendSrv().get(userRolesUrl); - if (!roles || !roles.length) { - return []; - } - return roles; - } catch (error) { - error.isHandled = true; - return []; - } -}; - -export const updateUserRoles = (roleUids: string[], userId: number, orgId?: number) => { - let userRolesUrl = `/api/access-control/users/${userId}/roles`; - if (orgId) { - userRolesUrl += `?targetOrgId=${orgId}`; - } - return getBackendSrv().put(userRolesUrl, { - orgId, - roleUids, - }); -}; diff --git a/public/app/core/components/RolePicker/api.ts b/public/app/core/components/RolePicker/api.ts new file mode 100644 index 00000000000..20ea7076242 --- /dev/null +++ b/public/app/core/components/RolePicker/api.ts @@ -0,0 +1,78 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { Role } from 'app/types'; + +export const fetchRoleOptions = async (orgId?: number, query?: string): Promise => { + let rolesUrl = '/api/access-control/roles?delegatable=true'; + if (orgId) { + rolesUrl += `&targetOrgId=${orgId}`; + } + const roles = await getBackendSrv().get(rolesUrl); + if (!roles || !roles.length) { + return []; + } + return roles; +}; + +export const fetchBuiltinRoles = (orgId?: number): Promise<{ [key: string]: Role[] }> => { + let builtinRolesUrl = '/api/access-control/builtin-roles'; + if (orgId) { + builtinRolesUrl += `?targetOrgId=${orgId}`; + } + return getBackendSrv().get(builtinRolesUrl); +}; + +export const fetchUserRoles = async (userId: number, orgId?: number): Promise => { + let userRolesUrl = `/api/access-control/users/${userId}/roles`; + if (orgId) { + userRolesUrl += `?targetOrgId=${orgId}`; + } + try { + const roles = await getBackendSrv().get(userRolesUrl); + if (!roles || !roles.length) { + return []; + } + return roles; + } catch (error) { + error.isHandled = true; + return []; + } +}; + +export const updateUserRoles = (roleUids: string[], userId: number, orgId?: number) => { + let userRolesUrl = `/api/access-control/users/${userId}/roles`; + if (orgId) { + userRolesUrl += `?targetOrgId=${orgId}`; + } + return getBackendSrv().put(userRolesUrl, { + orgId, + roleUids, + }); +}; + +export const fetchTeamRoles = async (teamId: number, orgId?: number): Promise => { + let teamRolesUrl = `/api/access-control/teams/${teamId}/roles`; + if (orgId) { + teamRolesUrl += `?targetOrgId=${orgId}`; + } + try { + const roles = await getBackendSrv().get(teamRolesUrl); + if (!roles || !roles.length) { + return []; + } + return roles; + } catch (error) { + error.isHandled = true; + return []; + } +}; + +export const updateTeamRoles = (roleUids: string[], teamId: number, orgId?: number) => { + let teamRolesUrl = `/api/access-control/teams/${teamId}/roles`; + if (orgId) { + teamRolesUrl += `?targetOrgId=${orgId}`; + } + return getBackendSrv().put(teamRolesUrl, { + orgId, + roleUids, + }); +}; diff --git a/public/app/core/reducers/root.test.ts b/public/app/core/reducers/root.test.ts index 6969a15b79b..d79f21266f8 100644 --- a/public/app/core/reducers/root.test.ts +++ b/public/app/core/reducers/root.test.ts @@ -67,6 +67,7 @@ describe('rootReducer', () => { expect(resultingState.teams).toEqual({ hasFetched: true, searchQuery: '', + searchPage: 1, teams, }); return true; @@ -81,6 +82,7 @@ describe('rootReducer', () => { teams: { hasFetched: true, searchQuery: '', + searchPage: 1, teams, }, } as StoreState; diff --git a/public/app/features/serviceaccounts/ServiceAccountsTable.tsx b/public/app/features/serviceaccounts/ServiceAccountsTable.tsx index 84ef5a920f9..c602db7b1c0 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsTable.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsTable.tsx @@ -4,7 +4,8 @@ import { OrgRolePicker } from '../admin/OrgRolePicker'; import { Button, ConfirmModal } from '@grafana/ui'; import { OrgRole } from '@grafana/data'; import { contextSrv } from 'app/core/core'; -import { fetchBuiltinRoles, fetchRoleOptions, UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; +import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; +import { fetchBuiltinRoles, fetchRoleOptions } from 'app/core/components/RolePicker/api'; export interface Props { serviceAccounts: OrgServiceAccount[]; diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx index 4eca8c0d192..aa53bf1d995 100644 --- a/public/app/features/teams/TeamList.test.tsx +++ b/public/app/features/teams/TeamList.test.tsx @@ -6,7 +6,7 @@ import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks'; import { contextSrv, User } from 'app/core/services/context_srv'; import { NavModel } from '@grafana/data'; import { mockToolkitActionCreator } from 'test/core/redux/mocks'; -import { setSearchQuery } from './state/reducers'; +import { setSearchQuery, setTeamsSearchPage } from './state/reducers'; jest.mock('app/core/config', () => { return { @@ -28,7 +28,9 @@ const setup = (propOverrides?: object) => { loadTeams: jest.fn(), deleteTeam: jest.fn(), setSearchQuery: mockToolkitActionCreator(setSearchQuery), + setTeamsSearchPage: mockToolkitActionCreator(setTeamsSearchPage), searchQuery: '', + searchPage: 1, teamsCount: 0, hasFetched: false, editorsCanAdmin: false, diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index 81bb4442a89..27c6af5be0b 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -1,39 +1,62 @@ import React, { PureComponent } from 'react'; import Page from 'app/core/components/Page/Page'; -import { DeleteButton, LinkButton, FilterInput } from '@grafana/ui'; +import { DeleteButton, LinkButton, FilterInput, VerticalGroup, HorizontalGroup, Pagination } from '@grafana/ui'; import { NavModel } from '@grafana/data'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import { AccessControlAction, StoreState, Team } from 'app/types'; +import { AccessControlAction, Role, StoreState, Team } from 'app/types'; import { deleteTeam, loadTeams } from './state/actions'; -import { getSearchQuery, getTeams, getTeamsCount, isPermissionTeamAdmin } from './state/selectors'; +import { getSearchQuery, getTeams, getTeamsCount, getTeamsSearchPage, isPermissionTeamAdmin } from './state/selectors'; import { getNavModel } from 'app/core/selectors/navModel'; import { config } from 'app/core/config'; import { contextSrv, User } from 'app/core/services/context_srv'; import { connectWithCleanUp } from '../../core/components/connectWithCleanUp'; -import { setSearchQuery } from './state/reducers'; +import { setSearchQuery, setTeamsSearchPage } from './state/reducers'; +import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker'; +import { fetchRoleOptions } from 'app/core/components/RolePicker/api'; + +const pageLimit = 30; export interface Props { navModel: NavModel; teams: Team[]; searchQuery: string; + searchPage: number; teamsCount: number; hasFetched: boolean; loadTeams: typeof loadTeams; deleteTeam: typeof deleteTeam; setSearchQuery: typeof setSearchQuery; + setTeamsSearchPage: typeof setTeamsSearchPage; editorsCanAdmin: boolean; signedInUser: User; } -export class TeamList extends PureComponent { +export interface State { + roleOptions: Role[]; +} + +export class TeamList extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { roleOptions: [] }; + } + componentDidMount() { this.fetchTeams(); + if (contextSrv.accessControlEnabled()) { + this.fetchRoleOptions(); + } } async fetchTeams() { await this.props.loadTeams(); } + async fetchRoleOptions() { + const roleOptions = await fetchRoleOptions(); + this.setState({ roleOptions }); + } + deleteTeam = (team: Team) => { this.props.deleteTeam(team.id); }; @@ -66,6 +89,11 @@ export class TeamList extends PureComponent { {team.memberCount} + {contextSrv.accessControlEnabled() && ( + + this.state.roleOptions} /> + + )} { ); } + getPaginatedTeams = (teams: Team[]) => { + const offset = (this.props.searchPage - 1) * pageLimit; + return teams.slice(offset, offset + pageLimit); + }; + renderTeamList() { - const { teams, searchQuery, editorsCanAdmin } = this.props; + 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 newTeamHref = canCreate ? 'org/teams/new' : '#'; + const paginatedTeams = this.getPaginatedTeams(teams); + const totalPages = Math.ceil(teams.length / pageLimit); return ( <> @@ -112,18 +147,29 @@ export class TeamList extends PureComponent {
- - - - - - - - - {teams.map((team) => this.renderTeam(team))} -
- NameEmailMembers -
+ + + + + + + + {contextSrv.accessControlEnabled() && } + + + {paginatedTeams.map((team) => this.renderTeam(team))} +
+ NameEmailMembersRoles +
+ + + +
); @@ -159,6 +205,7 @@ function mapStateToProps(state: StoreState) { navModel: getNavModel(state.navIndex, 'teams'), teams: getTeams(state.teams), searchQuery: getSearchQuery(state.teams), + searchPage: getTeamsSearchPage(state.teams), teamsCount: getTeamsCount(state.teams), hasFetched: state.teams.hasFetched, editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests, @@ -170,6 +217,7 @@ const mapDispatchToProps = { loadTeams, deleteTeam, setSearchQuery, + setTeamsSearchPage, }; export default connectWithCleanUp(mapStateToProps, mapDispatchToProps, (state) => state.teams)(TeamList); diff --git a/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap index f476372b9e6..88f24af4dd8 100644 --- a/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap @@ -57,303 +57,317 @@ exports[`Render should render teams table 1`] = `
- - - - - - - + + + + + - - - - - - - - - - - + - - Team avatar + Team avatar + + + + + + - + - - test-2 - - - + - + - - - - + + - + - - test-3 - - - + - + - - - - + + - + - - test-4 - - - + - + - - - - + + - + - - test-5 - - - + - + - - - -
- - Name - - Email - - Members - +
+ + Name + + Email + + Members + -
- - Team avatar - - - - test-1 - - - - test-1@test.com - - - - 1 - - - -
+ +
+ + test-1 + + + + test-1@test.com + + + + 1 + + + - - +
- + Team avatar + + - test-2@test.com - - - + test-2 + + - 2 - - - -
- + test-2@test.com + + - Team avatar + 2 + + + - - +
- + Team avatar + + - test-3@test.com - - - + test-3 + + - 3 - - - -
- + test-3@test.com + + - Team avatar + 3 + + + - - +
- + Team avatar + + - test-4@test.com - - - + test-4 + + - 4 - - - -
- + test-4@test.com + + - Team avatar + 4 + + + - - +
- + Team avatar + + - test-5@test.com - - - + test-5 + + - 5 - - - -
+ + test-5@test.com + + + + + 5 + + + + + + + + + + + +
@@ -397,87 +411,101 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
- - - - - - - + + + + + - - - - - - - - - - -
- - Name - - Email - - Members - +
+ + Name + + Email + + Members + -
- - Team avatar - - - - test-1 - - - - test-1@test.com - - - - 1 - - - -
+ + + + + + + Team avatar + + + + + test-1 + + + + + test-1@test.com + + + + + 1 + + + + + + + + + + + +
@@ -521,87 +549,101 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
- - - - - - - + + + + + - - - - - - - - - - -
- - Name - - Email - - Members - +
+ + Name + + Email + + Members + -
- - Team avatar - - - - test-1 - - - - test-1@test.com - - - - 1 - - - -
+ + + + + + + Team avatar + + + + + test-1 + + + + + test-1@test.com + + + + + 1 + + + + + + + + + + + +
diff --git a/public/app/features/teams/state/reducers.ts b/public/app/features/teams/state/reducers.ts index 392dbf1c5ac..5c3f193b21c 100644 --- a/public/app/features/teams/state/reducers.ts +++ b/public/app/features/teams/state/reducers.ts @@ -2,7 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { Team, TeamGroup, TeamMember, TeamsState, TeamState } from 'app/types'; -export const initialTeamsState: TeamsState = { teams: [], searchQuery: '', hasFetched: false }; +export const initialTeamsState: TeamsState = { teams: [], searchQuery: '', searchPage: 1, hasFetched: false }; const teamsSlice = createSlice({ name: 'teams', @@ -12,12 +12,15 @@ const teamsSlice = createSlice({ return { ...state, hasFetched: true, teams: action.payload }; }, setSearchQuery: (state, action: PayloadAction): TeamsState => { - return { ...state, searchQuery: action.payload }; + return { ...state, searchQuery: action.payload, searchPage: initialTeamsState.searchPage }; + }, + setTeamsSearchPage: (state, action: PayloadAction): TeamsState => { + return { ...state, searchPage: action.payload }; }, }, }); -export const { teamsLoaded, setSearchQuery } = teamsSlice.actions; +export const { teamsLoaded, setSearchQuery, setTeamsSearchPage } = 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 5d9981e0403..6a5dd24117d 100644 --- a/public/app/features/teams/state/selectors.test.ts +++ b/public/app/features/teams/state/selectors.test.ts @@ -8,14 +8,14 @@ describe('Teams selectors', () => { const mockTeams = getMultipleMockTeams(5); it('should return teams if no search query', () => { - const mockState: TeamsState = { teams: mockTeams, searchQuery: '', hasFetched: false }; + 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', hasFetched: false }; + const mockState: TeamsState = { teams: mockTeams, searchQuery: '5', searchPage: 1, hasFetched: false }; const teams = getTeams(mockState); expect(teams.length).toEqual(1); diff --git a/public/app/features/teams/state/selectors.ts b/public/app/features/teams/state/selectors.ts index 5911699d4a1..f8461c7eb3a 100644 --- a/public/app/features/teams/state/selectors.ts +++ b/public/app/features/teams/state/selectors.ts @@ -5,6 +5,7 @@ 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)) { diff --git a/public/app/features/users/UsersTable.tsx b/public/app/features/users/UsersTable.tsx index 2601c8342c5..bde73cf9325 100644 --- a/public/app/features/users/UsersTable.tsx +++ b/public/app/features/users/UsersTable.tsx @@ -4,7 +4,8 @@ import { OrgRolePicker } from '../admin/OrgRolePicker'; import { Button, ConfirmModal } from '@grafana/ui'; import { OrgRole } from '@grafana/data'; import { contextSrv } from 'app/core/core'; -import { fetchBuiltinRoles, fetchRoleOptions, UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; +import { fetchBuiltinRoles, fetchRoleOptions } from 'app/core/components/RolePicker/api'; +import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; export interface Props { users: OrgUser[]; diff --git a/public/app/types/teams.ts b/public/app/types/teams.ts index 7c247705289..1bf8605522e 100644 --- a/public/app/types/teams.ts +++ b/public/app/types/teams.ts @@ -28,6 +28,7 @@ export interface TeamGroup { export interface TeamsState { teams: Team[]; searchQuery: string; + searchPage: number; hasFetched: boolean; }