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
This commit is contained in:
Alexander Zobnin 2022-01-17 18:04:54 +03:00 committed by GitHub
parent e888a40531
commit 867ca5b59e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 806 additions and 582 deletions

View File

@ -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<Role[]>;
getRoleOptions: () => Promise<Role[]>;
getBuiltinRoles: () => Promise<Record<string, Role[]>>;
onRolesChange: (newRoles: string[]) => void;
onBuiltinRoleChange: (newRole: OrgRole) => void;
builtInRole?: OrgRole;
appliedRoles: Role[];
roleOptions: Role[];
builtInRoles?: Record<string, Role[]>;
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<Role[]>([]);
const [appliedRoles, setAppliedRoles] = useState<Role[]>([]);
const [selectedRoles, setSelectedRoles] = useState<Role[]>([]);
const [selectedBuiltInRole, setSelectedBuiltInRole] = useState<OrgRole>(builtInRole);
const [builtInRoles, setBuiltinRoles] = useState<Record<string, Role[]>>({});
const [selectedRoles, setSelectedRoles] = useState<Role[]>(appliedRoles);
const [selectedBuiltInRole, setSelectedBuiltInRole] = useState<OrgRole | undefined>(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<HTMLElement>) => {
@ -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 (
<HorizontalGroup justify="center">
<span>Loading...</span>
<Spinner size={16} />
</HorizontalGroup>
);
}
return (
@ -124,6 +112,7 @@ export const RolePicker = ({
onClose={onClose}
isFocused={isOpen}
disabled={disabled}
showBuiltInRole={showBuiltInRole}
/>
{isOpen && (
<RolePickerMenu
@ -136,6 +125,7 @@ export const RolePicker = ({
onUpdate={onUpdate}
showGroups={query.length === 0 || query.trim() === ''}
builtinRolesDisabled={builtinRolesDisabled}
showBuiltInRole={showBuiltInRole}
/>
)}
</ClickOutsideWrapper>

View File

@ -9,8 +9,9 @@ const stopPropagation = (event: React.MouseEvent<HTMLDivElement>) => event.stopP
interface InputProps extends HTMLProps<HTMLInputElement> {
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 ? (
<div className={styles.selectedRoles} onMouseDown={onOpen}>
<ValueContainer>{builtInRole}</ValueContainer>
{!!numberOfRoles && (
<Tooltip
content={
<div className={styles.tooltip}>
{appliedRoles?.map((role) => (
<p key={role.uid}>{role.displayName}</p>
))}
</div>
}
>
<div>
<ValueContainer>{`+${numberOfRoles} role${numberOfRoles > 1 ? 's' : ''}`}</ValueContainer>
</div>
</Tooltip>
)}
{showBuiltInRole && <ValueContainer>{builtInRole}</ValueContainer>}
<RolesLabel appliedRoles={appliedRoles} numberOfRoles={numberOfRoles} showBuiltInRole={showBuiltInRole} />
</div>
) : (
<div className={styles.wrapper}>
<ValueContainer>{builtInRole}</ValueContainer>
{showBuiltInRole && <ValueContainer>{builtInRole}</ValueContainer>}
{appliedRoles.map((role) => (
<ValueContainer key={role.uid}>{role.displayName}</ValueContainer>
))}
@ -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 ? (
<Tooltip
content={
<div className={styles.tooltip}>
{appliedRoles?.map((role) => (
<p key={role.uid}>{role.displayName}</p>
))}
</div>
}
>
<div>
<ValueContainer>{`${showBuiltInRole ? '+' : ''}${numberOfRoles} role${
numberOfRoles > 1 ? 's' : ''
}`}</ValueContainer>
</div>
</Tooltip>
) : (
!showBuiltInRole && (
<div>
<ValueContainer>No roles assigned</ValueContainer>
</div>
)
)}
</>
);
};
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)};
}
`,
});

View File

@ -30,15 +30,16 @@ const fixedRoleGroupNames: Record<string, string> = {
};
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<Role[]>(appliedRoles);
const [selectedBuiltInRole, setSelectedBuiltInRole] = useState<OrgRole>(builtInRole);
const [selectedBuiltInRole, setSelectedBuiltInRole] = useState<OrgRole | undefined>(builtInRole);
const [showSubMenu, setShowSubMenu] = useState(false);
const [openedMenuGroup, setOpenedMenuGroup] = useState('');
const [subMenuOptions, setSubMenuOptions] = useState<Role[]>([]);
@ -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 (
<div className={cx(styles.menu, customStyles.menuWrapper)}>
<div className={customStyles.menu} aria-label="Role picker menu">
<CustomScrollbar autoHide={false} autoHeightMax="300px" hideHorizontalTrack hideVerticalTrack>
<div className={customStyles.menuSection}>
<div className={customStyles.groupHeader}>Built-in roles</div>
<RadioButtonGroup
className={customStyles.builtInRoleSelector}
options={BuiltinRoleOption}
value={selectedBuiltInRole}
onChange={onSelectedBuiltinRoleChange}
fullWidth={true}
disabled={builtinRolesDisabled}
/>
</div>
{showBuiltInRole && (
<div className={customStyles.menuSection}>
<div className={customStyles.groupHeader}>Built-in roles</div>
<RadioButtonGroup
className={customStyles.builtInRoleSelector}
options={BuiltinRoleOption}
value={selectedBuiltInRole}
onChange={onSelectedBuiltinRoleChange}
fullWidth={true}
disabled={builtinRolesDisabled}
/>
</div>
)}
{!!fixedRoles.length &&
(showGroups && !!optionGroups.length ? (
<div className={customStyles.menuSection}>

View File

@ -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<Role[]>;
disabled?: boolean;
builtinRolesDisabled?: boolean;
}
export const TeamRolePicker: FC<Props> = ({ teamId, orgId, getRoleOptions, disabled, builtinRolesDisabled }) => {
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const [appliedRoles, setAppliedRoles] = useState<Role[]>([]);
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 (
<RolePicker
onRolesChange={(roles) => updateTeamRoles(roles, teamId, orgId)}
roleOptions={roleOptions}
appliedRoles={appliedRoles}
isLoading={loading}
disabled={disabled}
builtinRolesDisabled={builtinRolesDisabled}
/>
);
};

View File

@ -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<Props> = ({
disabled,
builtinRolesDisabled,
}) => {
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const [appliedRoles, setAppliedRoles] = useState<Role[]>([]);
const [builtInRoles, setBuiltinRoles] = useState<Record<string, Role[]>>({});
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 (
<RolePicker
builtInRole={builtInRole}
onRolesChange={(roles) => 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<Role[]> => {
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<Role[]> => {
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,
});
};

View File

@ -0,0 +1,78 @@
import { getBackendSrv } from '@grafana/runtime';
import { Role } from 'app/types';
export const fetchRoleOptions = async (orgId?: number, query?: string): Promise<Role[]> => {
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<Role[]> => {
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<Role[]> => {
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,
});
};

View File

@ -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;

View File

@ -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[];

View File

@ -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,

View File

@ -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<Props, any> {
export interface State {
roleOptions: Role[];
}
export class TeamList extends PureComponent<Props, State> {
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<Props, any> {
<td className="link-td">
<a href={teamUrl}>{team.memberCount}</a>
</td>
{contextSrv.accessControlEnabled() && (
<td>
<TeamRolePicker teamId={team.id} getRoleOptions={async () => this.state.roleOptions} />
</td>
)}
<td className="text-right">
<DeleteButton
aria-label="Delete team"
@ -93,11 +121,18 @@ export class TeamList extends PureComponent<Props, any> {
);
}
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<Props, any> {
</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.map((team) => this.renderTeam(team))}</tbody>
</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>
{contextSrv.accessControlEnabled() && <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>
</>
);
@ -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);

View File

@ -57,303 +57,317 @@ exports[`Render should render teams table 1`] = `
<div
className="admin-list-table"
>
<table
className="filter-table filter-table--hover form-inline"
<VerticalGroup
spacing="md"
>
<thead>
<tr>
<th />
<th>
Name
</th>
<th>
Email
</th>
<th>
Members
</th>
<th
style={
Object {
"width": "1%",
<table
className="filter-table filter-table--hover form-inline"
>
<thead>
<tr>
<th />
<th>
Name
</th>
<th>
Email
</th>
<th>
Members
</th>
<th
style={
Object {
"width": "1%",
}
}
}
/>
</tr>
</thead>
<tbody>
<tr
key="1"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/1"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
1
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
<tr
key="2"
>
<td
className="width-4 text-center link-td"
</tr>
</thead>
<tbody>
<tr
key="1"
>
<a
href="org/teams/edit/2"
<td
className="width-4 text-center link-td"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
<a
href="org/teams/edit/1"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
1
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</a>
</td>
<td
className="link-td"
</td>
</tr>
<tr
key="2"
>
<a
href="org/teams/edit/2"
<td
className="width-4 text-center link-td"
>
test-2
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/2"
<a
href="org/teams/edit/2"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
test-2@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/2"
<a
href="org/teams/edit/2"
>
test-2
</a>
</td>
<td
className="link-td"
>
2
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
<tr
key="3"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/3"
<a
href="org/teams/edit/2"
>
test-2@test.com
</a>
</td>
<td
className="link-td"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
<a
href="org/teams/edit/2"
>
2
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</a>
</td>
<td
className="link-td"
</td>
</tr>
<tr
key="3"
>
<a
href="org/teams/edit/3"
<td
className="width-4 text-center link-td"
>
test-3
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/3"
<a
href="org/teams/edit/3"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
test-3@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/3"
<a
href="org/teams/edit/3"
>
test-3
</a>
</td>
<td
className="link-td"
>
3
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
<tr
key="4"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/4"
<a
href="org/teams/edit/3"
>
test-3@test.com
</a>
</td>
<td
className="link-td"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
<a
href="org/teams/edit/3"
>
3
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</a>
</td>
<td
className="link-td"
</td>
</tr>
<tr
key="4"
>
<a
href="org/teams/edit/4"
<td
className="width-4 text-center link-td"
>
test-4
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/4"
<a
href="org/teams/edit/4"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
test-4@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/4"
<a
href="org/teams/edit/4"
>
test-4
</a>
</td>
<td
className="link-td"
>
4
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
<tr
key="5"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/5"
<a
href="org/teams/edit/4"
>
test-4@test.com
</a>
</td>
<td
className="link-td"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
<a
href="org/teams/edit/4"
>
4
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</a>
</td>
<td
className="link-td"
</td>
</tr>
<tr
key="5"
>
<a
href="org/teams/edit/5"
<td
className="width-4 text-center link-td"
>
test-5
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/5"
<a
href="org/teams/edit/5"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
test-5@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/5"
<a
href="org/teams/edit/5"
>
test-5
</a>
</td>
<td
className="link-td"
>
5
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
</tbody>
</table>
<a
href="org/teams/edit/5"
>
test-5@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/5"
>
5
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={false}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
</tbody>
</table>
<HorizontalGroup
justify="flex-end"
>
<Pagination
currentPage={1}
hideWhenSinglePage={true}
numberOfPages={1}
onNavigate={[MockFunction]}
/>
</HorizontalGroup>
</VerticalGroup>
</div>
</PageContents>
</Page>
@ -397,87 +411,101 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
<div
className="admin-list-table"
>
<table
className="filter-table filter-table--hover form-inline"
<VerticalGroup
spacing="md"
>
<thead>
<tr>
<th />
<th>
Name
</th>
<th>
Email
</th>
<th>
Members
</th>
<th
style={
Object {
"width": "1%",
<table
className="filter-table filter-table--hover form-inline"
>
<thead>
<tr>
<th />
<th>
Name
</th>
<th>
Email
</th>
<th>
Members
</th>
<th
style={
Object {
"width": "1%",
}
}
}
/>
</tr>
</thead>
<tbody>
<tr
key="1"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/1"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
1
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={true}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
</tbody>
</table>
</tr>
</thead>
<tbody>
<tr
key="1"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/1"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
1
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={true}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
</tbody>
</table>
<HorizontalGroup
justify="flex-end"
>
<Pagination
currentPage={1}
hideWhenSinglePage={true}
numberOfPages={1}
onNavigate={[MockFunction]}
/>
</HorizontalGroup>
</VerticalGroup>
</div>
</PageContents>
</Page>
@ -521,87 +549,101 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
<div
className="admin-list-table"
>
<table
className="filter-table filter-table--hover form-inline"
<VerticalGroup
spacing="md"
>
<thead>
<tr>
<th />
<th>
Name
</th>
<th>
Email
</th>
<th>
Members
</th>
<th
style={
Object {
"width": "1%",
<table
className="filter-table filter-table--hover form-inline"
>
<thead>
<tr>
<th />
<th>
Name
</th>
<th>
Email
</th>
<th>
Members
</th>
<th
style={
Object {
"width": "1%",
}
}
}
/>
</tr>
</thead>
<tbody>
<tr
key="1"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/1"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
1
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={true}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
</tbody>
</table>
</tr>
</thead>
<tbody>
<tr
key="1"
>
<td
className="width-4 text-center link-td"
>
<a
href="org/teams/edit/1"
>
<img
alt="Team avatar"
className="filter-table__avatar"
src="some/url/"
/>
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
test-1@test.com
</a>
</td>
<td
className="link-td"
>
<a
href="org/teams/edit/1"
>
1
</a>
</td>
<td
className="text-right"
>
<DeleteButton
aria-label="Delete team"
disabled={true}
onConfirm={[Function]}
size="sm"
/>
</td>
</tr>
</tbody>
</table>
<HorizontalGroup
justify="flex-end"
>
<Pagination
currentPage={1}
hideWhenSinglePage={true}
numberOfPages={1}
onNavigate={[MockFunction]}
/>
</HorizontalGroup>
</VerticalGroup>
</div>
</PageContents>
</Page>

View File

@ -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<string>): TeamsState => {
return { ...state, searchQuery: action.payload };
return { ...state, searchQuery: action.payload, searchPage: initialTeamsState.searchPage };
},
setTeamsSearchPage: (state, action: PayloadAction<number>): TeamsState => {
return { ...state, searchPage: action.payload };
},
},
});
export const { teamsLoaded, setSearchQuery } = teamsSlice.actions;
export const { teamsLoaded, setSearchQuery, setTeamsSearchPage } = teamsSlice.actions;
export const teamsReducer = teamsSlice.reducer;

View File

@ -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);

View File

@ -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)) {

View File

@ -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[];

View File

@ -28,6 +28,7 @@ export interface TeamGroup {
export interface TeamsState {
teams: Team[];
searchQuery: string;
searchPage: number;
hasFetched: boolean;
}