mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
e888a40531
commit
867ca5b59e
@ -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>
|
||||
|
@ -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)};
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
@ -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}>
|
||||
|
42
public/app/core/components/RolePicker/TeamRolePicker.tsx
Normal file
42
public/app/core/components/RolePicker/TeamRolePicker.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
78
public/app/core/components/RolePicker/api.ts
Normal file
78
public/app/core/components/RolePicker/api.ts
Normal 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,
|
||||
});
|
||||
};
|
@ -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;
|
||||
|
@ -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[];
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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)) {
|
||||
|
@ -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[];
|
||||
|
@ -28,6 +28,7 @@ export interface TeamGroup {
|
||||
export interface TeamsState {
|
||||
teams: Team[];
|
||||
searchQuery: string;
|
||||
searchPage: number;
|
||||
hasFetched: boolean;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user