Teams: Refactor most functionality to use hooks (#113713)

This commit is contained in:
Tom Ratcliffe
2025-11-28 12:42:36 +00:00
committed by GitHub
parent 43c3322caf
commit 3c76e9ee72
31 changed files with 384 additions and 596 deletions
-5
View File
@@ -3183,11 +3183,6 @@
"count": 1
}
},
"public/app/features/teams/state/reducers.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/features/templating/fieldAccessorCache.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -1644,7 +1644,12 @@ const injectedRtkApi = api
invalidatesTags: ['teams'],
}),
getTeamById: build.query<GetTeamByIdApiResponse, GetTeamByIdApiArg>({
query: (queryArg) => ({ url: `/teams/${queryArg.teamId}` }),
query: (queryArg) => ({
url: `/teams/${queryArg.teamId}`,
params: {
accesscontrol: queryArg.accesscontrol,
},
}),
providesTags: ['teams'],
}),
updateTeam: build.mutation<UpdateTeamApiResponse, UpdateTeamApiArg>({
@@ -3474,6 +3479,7 @@ export type DeleteTeamByIdApiArg = {
export type GetTeamByIdApiResponse = /** status 200 (empty) */ TeamDto;
export type GetTeamByIdApiArg = {
teamId: string;
accesscontrol?: boolean;
};
export type UpdateTeamApiResponse =
/** status 200 An OKResponse is returned if the request was successful. */ SuccessResponseBody;
@@ -6051,10 +6057,8 @@ export type SearchTeamGroupsQueryResult = {
totalCount?: number;
};
export type UpdateTeamCommand = {
Email?: string;
ExternalUID?: string;
ID?: number;
Name?: string;
email?: string;
name?: string;
};
export type TeamMemberDto = {
auth_module?: string;
@@ -1,5 +1,6 @@
import { HttpHandler } from 'msw';
import accessControlHandlers from './api/access-control/handlers';
import dashboardsHandlers from './api/dashboards/handlers';
import folderHandlers from './api/folders/handlers';
import pluginsHandlers from './api/plugins/handlers';
@@ -14,11 +15,12 @@ import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/hand
const allHandlers: HttpHandler[] = [
// Legacy handlers
...teamsHandlers,
...accessControlHandlers,
...dashboardsHandlers,
...folderHandlers,
...searchHandlers,
...pluginsHandlers,
...searchHandlers,
...teamsHandlers,
...userHandlers,
// App platform handlers
@@ -0,0 +1,11 @@
import { HttpResponse, http } from 'msw';
const searchTeamRolesHandler = () =>
http.post('/api/access-control/teams/roles/search', async () => {
// TODO: Add better mock roles response as needed
return HttpResponse.json([]);
});
const handlers = [searchTeamRolesHandler()];
export default handlers;
@@ -127,6 +127,26 @@ const createTeamHandler = () =>
return HttpResponse.json({ message: 'Team created', teamId: 10, uid: 'aethyfifmhwcgd' }, { status: 200 });
});
const updateTeamHandler = () =>
http.put<{ uid: string }, { name: string; email: string }>('/api/teams/:uid', async ({ params, request }) => {
const teamData = mockTeamsMap.get(params.uid);
const body = await request.json();
if (!teamData) {
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
}
const updatedTeam = {
...teamData,
team: {
...teamData.team,
name: body.name,
email: body.email,
},
};
mockTeamsMap.set(params.uid, updatedTeam);
return HttpResponse.json({ message: 'Team updated' });
});
const handlers = [
teamsPreferencesHandler(),
teamsGroupsHandler(),
@@ -135,6 +155,7 @@ const handlers = [
getTeamHandler(),
deleteTeamHandler(),
createTeamHandler(),
updateTeamHandler(),
];
export default handlers;
+5 -5
View File
@@ -47,11 +47,11 @@ type CreateTeamCommand struct {
}
type UpdateTeamCommand struct {
ID int64
Name string
Email string
ExternalUID string
OrgID int64 `json:"-"`
ID int64 `json:"-"`
Name string `json:"name"`
Email string `json:"email"`
ExternalUID string `json:"-"`
OrgID int64 `json:"-"`
}
type DeleteTeamCommand struct {
+4
View File
@@ -312,6 +312,10 @@ type GetTeamByIDParams struct {
// in:path
// required:true
TeamID string `json:"team_id"`
// in:query
// required:false
// default: false
AccessControl bool `json:"accesscontrol"`
}
// swagger:parameters deleteTeamByID
+2 -9
View File
@@ -8744,17 +8744,10 @@
"UpdateTeamCommand": {
"type": "object",
"properties": {
"Email": {
"email": {
"type": "string"
},
"ExternalUID": {
"type": "string"
},
"ID": {
"type": "integer",
"format": "int64"
},
"Name": {
"name": {
"type": "string"
}
}
+8 -9
View File
@@ -10117,6 +10117,12 @@
"name": "team_id",
"in": "path",
"required": true
},
{
"type": "boolean",
"default": false,
"name": "accesscontrol",
"in": "query"
}
],
"responses": {
@@ -23164,17 +23170,10 @@
"UpdateTeamCommand": {
"type": "object",
"properties": {
"Email": {
"email": {
"type": "string"
},
"ExternalUID": {
"type": "string"
},
"ID": {
"type": "integer",
"format": "int64"
},
"Name": {
"name": {
"type": "string"
}
}
+1 -3
View File
@@ -2,7 +2,5 @@ import { generatedAPI } from '@grafana/api-clients/rtkq/iam/v0alpha1';
export const iamAPIv0alpha1 = generatedAPI.enhanceEndpoints({});
export const { useGetDisplayMappingQuery, useLazyGetDisplayMappingQuery } = iamAPIv0alpha1;
// eslint-disable-next-line no-barrel-files/no-barrel-files
export type { DisplayList } from '@grafana/api-clients/rtkq/iam/v0alpha1';
export * from '@grafana/api-clients/rtkq/iam/v0alpha1';
@@ -1,3 +1,4 @@
import { RoleDto } from 'app/api/clients/legacy';
import { Role } from 'app/types/accessControl';
export const isNotDelegatable = (role: Role) => {
@@ -23,9 +24,10 @@ export const addDisplayNameForFixedRole = (role: Role) => {
// Adds a display name for use when the list of roles is filtered
// If either group or displayName are undefined, we fall back (see RoleMenuOption.tsx)
export const addFilteredDisplayName = (role: Role) => {
if (role.group && role.displayName) {
role.filteredDisplayName = role.group + ':' + role.displayName;
}
return role;
export const addFilteredDisplayName = (role: RoleDto): Role => {
const filteredDisplayName = role.group && role.displayName ? `${role.group}:${role.displayName}` : '';
return {
...role,
filteredDisplayName,
};
};
-64
View File
@@ -1,64 +0,0 @@
import { Team } from 'app/types/teams';
import { reducerTester } from '../../../test/core/redux/reducerTester';
import { initialTeamsState, teamsLoaded } from '../../features/teams/state/reducers';
import { StoreState } from '../../types/store';
import { cleanUpAction } from '../actions/cleanUp';
import { createRootReducer } from './root';
describe('rootReducer', () => {
const rootReducer = createRootReducer();
describe('when called with any action except cleanUpAction', () => {
it('then it should not clean state', () => {
const teams = [{ id: 1 } as Team];
const state = {
teams: { ...initialTeamsState },
} as StoreState;
reducerTester<StoreState>()
.givenReducer(rootReducer, state)
.whenActionIsDispatched(teamsLoaded({ teams: teams, page: 1, noTeams: false, perPage: 30, totalCount: 1 }))
.thenStatePredicateShouldEqual((resultingState) => {
expect(resultingState.teams).toEqual({
hasFetched: true,
noTeams: false,
perPage: 30,
totalPages: 1,
query: '',
page: 1,
teams,
});
return true;
});
});
});
describe('when called with cleanUpAction', () => {
it('then it should clean state', () => {
const teams = [{ id: 1 }] as Team[];
const state: StoreState = {
teams: {
hasFetched: true,
query: '',
page: 1,
noTeams: false,
totalPages: 1,
perPage: 30,
teams,
},
} as StoreState;
reducerTester<StoreState>()
.givenReducer(rootReducer, state, false, true)
.whenActionIsDispatched(
cleanUpAction({ cleanupAction: (storeState) => (storeState.teams = initialTeamsState) })
)
.thenStatePredicateShouldEqual((resultingState) => {
expect(resultingState.teams).toEqual({ ...initialTeamsState });
return true;
});
});
});
});
@@ -8,7 +8,7 @@ import { MOCK_TEAMS } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
import { contextSrv } from 'app/core/services/context_srv';
import { CreateTeam } from './CreateTeam';
import CreateTeam from './CreateTeam';
setBackendSrv(backendSrv);
setupMockServer();
+35 -17
View File
@@ -3,16 +3,19 @@ import { useForm } from 'react-hook-form';
import { NavModelItem } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getBackendSrv, locationService } from '@grafana/runtime';
import { locationService } from '@grafana/runtime';
import { Button, Field, Input, FieldSet, Stack } from '@grafana/ui';
import { extractErrorMessage } from 'app/api/utils';
import { Page } from 'app/core/components/Page/Page';
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
import { updateTeamRoles } from 'app/core/components/RolePicker/api';
import { useRoleOptions } from 'app/core/components/RolePicker/hooks';
import { useAppNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/services/context_srv';
import { Role, AccessControlAction } from 'app/types/accessControl';
import { Role } from 'app/types/accessControl';
import { TeamDTO } from 'app/types/teams';
import { useCreateTeam } from './hooks';
const pageNav: NavModelItem = {
icon: 'users-alt',
id: 'team-new',
@@ -20,8 +23,11 @@ const pageNav: NavModelItem = {
subTitle: 'Create a new team. Teams let you grant permissions to a group of users.',
};
export const CreateTeam = (): JSX.Element => {
const CreateTeam = (): JSX.Element => {
const currentOrgId = contextSrv.user.orgId;
const notifyApp = useAppNotification();
const [createTeamTrigger] = useCreateTeam();
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
const [{ roleOptions }] = useRoleOptions(currentOrgId);
const {
@@ -30,21 +36,28 @@ export const CreateTeam = (): JSX.Element => {
formState: { errors },
} = useForm<TeamDTO>();
const canUpdateRoles =
contextSrv.hasPermission(AccessControlAction.ActionUserRolesAdd) &&
contextSrv.hasPermission(AccessControlAction.ActionUserRolesRemove);
const createTeam = async (formModel: TeamDTO) => {
try {
const newTeam = await getBackendSrv().post('/api/teams', formModel);
if (newTeam.teamId) {
await contextSrv.fetchUserPermissions();
if (contextSrv.licensedAccessControlEnabled() && canUpdateRoles) {
await updateTeamRoles(pendingRoles, newTeam.teamId, newTeam.orgId);
}
locationService.push(`/org/teams/edit/${newTeam.uid}`);
const { data, error } = await createTeamTrigger(
{
email: formModel.email || '',
name: formModel.name,
},
pendingRoles
);
const errorMessage = error ? extractErrorMessage(error) : undefined;
if (errorMessage) {
notifyApp.error(errorMessage);
return;
}
if (data && data.uid) {
locationService.push(`/org/teams/edit/${data.uid}`);
}
} catch (e) {
notifyApp.error(t('teams.create-team.failed-to-create', 'Failed to create team'));
console.error(e);
}
};
@@ -85,8 +98,13 @@ export const CreateTeam = (): JSX.Element => {
'This is optional and is primarily used for allowing custom team avatars'
)}
>
{/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */}
<Input {...register('email')} type="email" id="team-email" placeholder="email@test.com" />
<Input
{...register('email')}
type="email"
id="team-email"
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="email@test.com"
/>
</Field>
</Stack>
</FieldSet>
@@ -4,7 +4,7 @@ import { setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { MOCK_TEAMS } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
import { Team, TeamGroup, TeamState } from 'app/types/teams';
import { TeamGroup, TeamState } from 'app/types/teams';
import TeamGroupSync from './TeamGroupSync';
import { getMockTeamGroups } from './mocks/teamMocks';
@@ -13,12 +13,10 @@ setBackendSrv(backendSrv);
setupMockServer();
const setup = (preloadedTeamState?: Partial<TeamState>) => {
return render(<TeamGroupSync isReadOnly={false} />, {
return render(<TeamGroupSync teamUid={MOCK_TEAMS[0].metadata.name} isReadOnly={false} />, {
preloadedState: {
team: {
members: [],
groups: [],
team: { uid: MOCK_TEAMS[0].metadata.name } as Team,
...preloadedTeamState,
},
},
+4 -3
View File
@@ -29,6 +29,7 @@ const mapDispatchToProps = {
interface OwnProps {
isReadOnly: boolean;
teamUid: string;
}
interface State {
@@ -52,7 +53,7 @@ export class TeamGroupSync extends PureComponent<Props, State> {
}
async fetchTeamGroups() {
this.props.loadTeamGroups();
this.props.loadTeamGroups(this.props.teamUid);
}
onToggleAdding = () => {
@@ -65,12 +66,12 @@ export class TeamGroupSync extends PureComponent<Props, State> {
onAddGroup: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
this.props.addTeamGroup(this.state.newGroupId);
this.props.addTeamGroup(this.props.teamUid, this.state.newGroupId);
this.setState({ isAdding: false, newGroupId: '' });
};
onRemoveGroup = (group: TeamGroup) => {
this.props.removeTeamGroup(group.groupId);
this.props.removeTeamGroup(this.props.teamUid, group.groupId);
};
isNewGroupValid() {
+3 -3
View File
@@ -40,16 +40,16 @@ describe('TeamList', () => {
it('should enable the new team button', async () => {
render(<TeamList />);
expect(screen.getByRole('link', { name: /new team/i })).not.toHaveStyle('pointer-events: none');
expect(await screen.findByRole('link', { name: /new team/i })).not.toHaveStyle('pointer-events: none');
});
});
describe('when user does not have access to create a team', () => {
it('should disable the new team button', () => {
it('should disable the new team button', async () => {
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
render(<TeamList />);
expect(screen.getByRole('link', { name: /new team/i })).toHaveStyle('pointer-events: none');
expect(await screen.findByRole('link', { name: /new team/i })).toHaveStyle('pointer-events: none');
});
});
});
+51 -66
View File
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Skeleton from 'react-loading-skeleton';
import { connect, ConnectedProps } from 'react-redux';
import { SortingRule } from 'react-table';
import { Trans, t } from '@grafana/i18n';
import {
@@ -14,6 +14,7 @@ import {
InlineField,
InteractiveTable,
LinkButton,
LoadingPlaceholder,
Pagination,
Stack,
Tag,
@@ -24,16 +25,14 @@ import { Page } from 'app/core/components/Page/Page';
import { fetchRoleOptions } from 'app/core/components/RolePicker/api';
import { contextSrv } from 'app/core/services/context_srv';
import { Role, AccessControlAction } from 'app/types/accessControl';
import { StoreState } from 'app/types/store';
import { TeamWithRoles } from 'app/types/teams';
import { TeamRolePicker } from '../../core/components/RolePicker/TeamRolePicker';
import { EnterpriseAuthFeaturesCard } from '../admin/EnterpriseAuthFeaturesCard';
import { deleteTeam, loadTeams, changePage, changeQuery, changeSort } from './state/actions';
import { useDeleteTeam, useGetTeams } from './hooks';
type Cell<T extends keyof TeamWithRoles = keyof TeamWithRoles> = CellProps<TeamWithRoles, TeamWithRoles[T]>;
export interface OwnProps {}
export interface State {
roleOptions: Role[];
@@ -49,26 +48,31 @@ const skeletonData: TeamWithRoles[] = new Array(3).fill(null).map((_, index) =>
isProvisioned: false,
}));
const TeamList = ({
teams,
query,
noTeams,
hasFetched,
loadTeams,
deleteTeam,
changeQuery,
totalPages,
page,
rolesLoading,
changePage,
changeSort,
}: Props) => {
const TeamList = () => {
const canCreate = contextSrv.hasPermission(AccessControlAction.ActionTeamsCreate);
const displayRolePicker = shouldDisplayRolePicker();
const pageSize = 20;
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const styles = useStyles2(getStyles);
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [sort, setSort] = useState<string>();
const { data, isLoading } = useGetTeams({ query, pageSize, page, sort });
const [deleteTeam] = useDeleteTeam();
useEffect(() => {
loadTeams(true);
}, [loadTeams]);
const teams = data?.teams || [];
const totalPages = Math.ceil((data?.totalCount || 0) / pageSize) || 0;
const noTeams = teams?.length === 0;
const changeSort = useCallback(
(sort: SortingRule<unknown>) => {
setSort(`${sort.id}-${sort.desc ? 'desc' : 'asc'}`);
},
[setSort]
);
const changePage = (page: number) => {
setPage(page);
};
useEffect(() => {
if (contextSrv.licensedAccessControlEnabled() && contextSrv.hasPermission(AccessControlAction.ActionRolesList)) {
@@ -76,9 +80,6 @@ const TeamList = ({
}
}, []);
const canCreate = contextSrv.hasPermission(AccessControlAction.ActionTeamsCreate);
const displayRolePicker = shouldDisplayRolePicker();
const columns: Array<Column<TeamWithRoles>> = useMemo(
() => [
{
@@ -86,7 +87,7 @@ const TeamList = ({
header: '',
disableGrow: true,
cell: ({ cell: { value } }: Cell<'avatarUrl'>) => {
if (!hasFetched) {
if (isLoading) {
return <Skeleton containerClassName={styles.blockSkeleton} width={24} height={24} circle />;
}
@@ -97,7 +98,7 @@ const TeamList = ({
id: 'name',
header: 'Name',
cell: ({ cell: { value }, row: { original } }: Cell<'name'>) => {
if (!hasFetched) {
if (isLoading) {
return <Skeleton width={100} />;
}
@@ -123,7 +124,7 @@ const TeamList = ({
id: 'email',
header: 'Email',
cell: ({ cell: { value } }: Cell<'email'>) => {
if (!hasFetched) {
if (isLoading) {
return <Skeleton width={60} />;
}
return value;
@@ -135,7 +136,7 @@ const TeamList = ({
header: 'Members',
disableGrow: true,
cell: ({ cell: { value } }: Cell<'memberCount'>) => {
if (!hasFetched) {
if (isLoading) {
return <Skeleton width={40} />;
}
return value;
@@ -147,8 +148,8 @@ const TeamList = ({
{
id: 'role',
header: 'Role',
cell: ({ cell: { value }, row: { original } }: Cell<'memberCount'>) => {
if (!hasFetched) {
cell: ({ row: { original } }: Cell<'memberCount'>) => {
if (isLoading) {
return <Skeleton width={320} height={32} containerClassName={styles.blockSkeleton} />;
}
const canSeeTeamRoles = contextSrv.hasPermissionInMetadata(
@@ -160,7 +161,7 @@ const TeamList = ({
<TeamRolePicker
teamId={original.id}
roles={original.roles || []}
isLoading={rolesLoading}
isLoading={isLoading}
roleOptions={roleOptions}
width={40}
/>
@@ -174,7 +175,7 @@ const TeamList = ({
id: 'isProvisioned',
header: '',
cell: ({ cell: { value } }: Cell<'isProvisioned'>) => {
if (!hasFetched) {
if (isLoading) {
return <Skeleton width={240} />;
}
return !!value && <Tag colorIndex={14} name={'Provisioned'} />;
@@ -185,7 +186,7 @@ const TeamList = ({
header: '',
disableGrow: true,
cell: ({ row: { original } }: Cell) => {
if (!hasFetched) {
if (isLoading) {
return (
<Stack direction="row" justifyContent="flex-end" alignItems="center">
<Skeleton containerClassName={styles.blockSkeleton} width={16} height={16} />
@@ -216,14 +217,14 @@ const TeamList = ({
})}
size="sm"
disabled={!canDelete}
onConfirm={() => deleteTeam(original.uid)}
onConfirm={() => deleteTeam({ uid: original.uid })}
/>
</Stack>
);
},
},
],
[displayRolePicker, hasFetched, rolesLoading, roleOptions, deleteTeam, styles]
[displayRolePicker, isLoading, styles.blockSkeleton, roleOptions, deleteTeam]
);
return (
@@ -238,7 +239,7 @@ const TeamList = ({
}
>
<Page.Contents>
{noTeams ? (
{!isLoading && !query && teams.length === 0 ? (
<EmptyState
variant="call-to-action"
button={
@@ -262,19 +263,26 @@ const TeamList = ({
<FilterInput
placeholder={t('teams.team-list.placeholder-search-teams', 'Search teams')}
value={query}
onChange={changeQuery}
onChange={setQuery}
/>
</InlineField>
</div>
{hasFetched && teams.length === 0 ? (
{!isLoading && teams.length === 0 && (
<EmptyState variant="not-found" message={t('teams.empty-state.message', 'No teams found')} />
) : (
)}
{isLoading && <LoadingPlaceholder text={t('teams.team-list.loading-teams', 'Loading teams...')} />}
{!isLoading && teams.length > 0 && (
<Stack direction={'column'} gap={2}>
<InteractiveTable
columns={columns}
data={hasFetched ? teams : skeletonData}
data={isLoading ? skeletonData : teams}
getRowId={(team) => String(team.id)}
fetchData={changeSort}
fetchData={({ sortBy }) => {
const sortingRule = sortBy.at(0);
if (sortingRule) {
return changeSort(sortingRule);
}
}}
/>
<Stack justifyContent="flex-end">
<Pagination
@@ -302,30 +310,7 @@ function shouldDisplayRolePicker(): boolean {
);
}
function mapStateToProps(state: StoreState) {
return {
teams: state.teams.teams,
query: state.teams.query,
perPage: state.teams.perPage,
page: state.teams.page,
noTeams: state.teams.noTeams,
totalPages: state.teams.totalPages,
hasFetched: state.teams.hasFetched,
rolesLoading: state.teams.rolesLoading,
};
}
const mapDispatchToProps = {
loadTeams,
deleteTeam,
changePage,
changeQuery,
changeSort,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = OwnProps & ConnectedProps<typeof connector>;
export default connector(TeamList);
export default TeamList;
const getStyles = () => ({
blockSkeleton: css({
+5 -14
View File
@@ -1,7 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { memo, useRef } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { useAsync } from 'react-use';
import { featureEnabled } from '@grafana/runtime';
import { Page } from 'app/core/components/Page/Page';
@@ -10,14 +9,13 @@ import config from 'app/core/config';
import { getNavModel } from 'app/core/selectors/navModel';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types/accessControl';
import { StoreState, useDispatch, useSelector } from 'app/types/store';
import { StoreState, useSelector } from 'app/types/store';
import TeamGroupSync, { TeamSyncUpgradeContent } from './TeamGroupSync';
import TeamPermissions from './TeamPermissions';
import TeamSettings from './TeamSettings';
import { loadTeam } from './state/actions';
import { useGetTeam } from './hooks';
import { getTeamLoadingNav } from './state/navModel';
import { getTeam } from './state/selectors';
type TeamPageRouteParams = {
uid: string;
@@ -32,11 +30,6 @@ enum PageTypes {
const PAGES = ['members', 'settings', 'groupsync'];
const teamSelector = createSelector(
[(state: StoreState) => state.team, (_: StoreState, teamUid: string) => teamUid],
(team, teamUid) => getTeam(team, teamUid)
);
const pageNavSelector = createSelector(
[
(state: StoreState) => state.navIndex,
@@ -52,7 +45,8 @@ const pageNavSelector = createSelector(
const TeamPages = memo(() => {
const isSyncEnabled = useRef(featureEnabled('teamsync'));
const { uid: teamUid = '', page } = useParams<TeamPageRouteParams>();
const team = useSelector((state) => teamSelector(state, teamUid));
const { data: team, isLoading } = useGetTeam({ uid: teamUid });
let defaultPage = 'members';
// With RBAC the settings page will always be available
@@ -62,9 +56,6 @@ const TeamPages = memo(() => {
const pageName = page ?? defaultPage;
const pageNav = useSelector((state) => pageNavSelector(state, pageName, teamUid));
const dispatch = useDispatch();
const { loading: isLoading } = useAsync(async () => dispatch(loadTeam(teamUid)), [teamUid]);
const renderPage = () => {
const currentPage = PAGES.includes(pageName) ? pageName : PAGES[0];
@@ -89,7 +80,7 @@ const TeamPages = memo(() => {
case PageTypes.GroupSync:
if (isSyncEnabled.current) {
if (canReadTeamPermissions) {
return <TeamGroupSync isReadOnly={!canWriteTeamPermissions} />;
return <TeamGroupSync isReadOnly={!canWriteTeamPermissions} teamUid={teamUid} />;
}
} else if (config.featureToggles.featureHighlights) {
return (
+14 -14
View File
@@ -1,12 +1,13 @@
import { render, screen, waitFor } from 'test/test-utils';
import { render, screen } from 'test/test-utils';
import { setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { MOCK_TEAMS } from '@grafana/test-utils/unstable';
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
import { backendSrv } from 'app/core/services/backend_srv';
import { contextSrv } from 'app/core/services/context_srv';
import { Props, TeamSettings } from './TeamSettings';
import TeamSettings from './TeamSettings';
jest.spyOn(contextSrv, 'hasPermission').mockImplementation(() => true);
jest.spyOn(contextSrv, 'hasPermissionInMetadata').mockImplementation(() => true);
@@ -14,9 +15,9 @@ jest.spyOn(contextSrv, 'hasPermissionInMetadata').mockImplementation(() => true)
setBackendSrv(backendSrv);
setupMockServer();
const setup = (propOverrides?: object) => {
const setup = () => {
const team = MOCK_TEAMS[0];
const props: Props = {
const props = {
team: {
id: Number(team.metadata.labels['grafana.app/deprecatedInternalID']),
uid: team.metadata.name,
@@ -25,12 +26,14 @@ const setup = (propOverrides?: object) => {
orgId: 1,
isProvisioned: false,
},
updateTeam: jest.fn(),
};
Object.assign(props, propOverrides);
return render(<TeamSettings {...props} />);
return render(
<>
<AppNotificationList />
<TeamSettings {...props} />
</>
);
};
describe('Team settings', () => {
@@ -41,8 +44,7 @@ describe('Team settings', () => {
});
it('should validate required fields', async () => {
const mockUpdate = jest.fn();
const { user } = setup({ updateTeam: mockUpdate });
const { user } = setup();
await screen.findByText('Team details');
await user.clear(screen.getByRole('textbox', { name: /Name/ }));
@@ -50,12 +52,10 @@ describe('Team settings', () => {
await user.click(screen.getByRole('button', { name: 'Save team details' }));
expect(await screen.findByText('Name is required')).toBeInTheDocument();
await waitFor(() => expect(mockUpdate).not.toHaveBeenCalled());
});
it('should submit form with correct values', async () => {
const mockUpdate = jest.fn();
const { user } = setup({ updateTeam: mockUpdate });
const { user } = setup();
await user.clear(screen.getByRole('textbox', { name: /Name/ }));
await user.clear(screen.getByLabelText(/Email/i));
@@ -63,6 +63,6 @@ describe('Team settings', () => {
await user.type(screen.getByLabelText(/Email/i), 'team@test.com');
await user.click(screen.getByRole('button', { name: 'Save team details' }));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith('New team', 'team@test.com'));
expect(await screen.findByText('Team updated')).toBeInTheDocument();
});
});
+12 -13
View File
@@ -1,5 +1,4 @@
import { useForm } from 'react-hook-form';
import { ConnectedProps, connect } from 'react-redux';
import { Trans, t } from '@grafana/i18n';
import { Button, Field, FieldSet, Input, Stack } from '@grafana/ui';
@@ -10,22 +9,16 @@ import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types/accessControl';
import { Team } from 'app/types/teams';
import { updateTeam } from './state/actions';
import { useUpdateTeam } from './hooks';
const mapDispatchToProps = {
updateTeam,
};
const connector = connect(null, mapDispatchToProps);
interface OwnProps {
interface Props {
team: Team;
}
export type Props = ConnectedProps<typeof connector> & OwnProps;
export const TeamSettings = ({ team, updateTeam }: Props) => {
const TeamSettings = ({ team }: Props) => {
const canWriteTeamSettings = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsWrite, team);
const currentOrgId = contextSrv.user.orgId;
const [updateTeam] = useUpdateTeam();
const [{ roleOptions }] = useRoleOptions(currentOrgId);
const {
@@ -43,7 +36,13 @@ export const TeamSettings = ({ team, updateTeam }: Props) => {
contextSrv.hasPermission(AccessControlAction.ActionRolesList);
const onSubmit = async (formTeam: Team) => {
updateTeam(formTeam.name, formTeam.email || '');
return updateTeam({
uid: team.uid,
team: {
name: formTeam.name,
email: formTeam.email || '',
},
});
};
return (
@@ -103,4 +102,4 @@ export const TeamSettings = ({ team, updateTeam }: Props) => {
);
};
export default connector(TeamSettings);
export default TeamSettings;
+154
View File
@@ -0,0 +1,154 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useEffect, useMemo } from 'react';
import {
useSearchTeamsQuery as useLegacySearchTeamsQuery,
useCreateTeamMutation,
useDeleteTeamByIdMutation,
useListTeamsRolesQuery,
CreateTeamCommand,
useSetTeamRolesMutation,
useGetTeamByIdQuery,
useUpdateTeamMutation,
UpdateTeamCommand,
} from 'app/api/clients/legacy';
import { updateNavIndex } from 'app/core/actions';
import { addFilteredDisplayName } from 'app/core/components/RolePicker/utils';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, Role } from 'app/types/accessControl';
import { useDispatch } from 'app/types/store';
import { buildNavModel } from './state/navModel';
const rolesEnabled =
contextSrv.licensedAccessControlEnabled() && contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList);
const canUpdateRoles = () =>
contextSrv.hasPermission(AccessControlAction.ActionUserRolesAdd) &&
contextSrv.hasPermission(AccessControlAction.ActionUserRolesRemove);
/**
* Get list of teams and their associated roles (if roles are enabled)
*/
export const useGetTeams = ({
query,
pageSize,
page,
sort,
}: {
query?: string;
pageSize?: number;
page?: number;
sort?: string;
}) => {
const legacyResponse = useLegacySearchTeamsQuery({ perpage: pageSize, accesscontrol: true, page, sort, query });
const teamIds = useMemo(() => {
const teams = legacyResponse.data?.teams || [];
const ids = teams.map((team) => team.id);
return ids.filter((id): id is number => id !== undefined);
}, [legacyResponse.data?.teams]);
const teamsRolesResponse = useListTeamsRolesQuery(
rolesEnabled && teamIds.length ? { rolesSearchQuery: { teamIds } } : skipToken
);
const teamsWithRoles = useMemo(() => {
if (!rolesEnabled || (rolesEnabled && teamsRolesResponse.isLoading)) {
return legacyResponse.data?.teams || [];
}
return (legacyResponse.data?.teams || []).map((team) => {
const roles = team.id ? teamsRolesResponse.data?.[team.id] || [] : [];
const mappedRoles = roles.map((role) => addFilteredDisplayName(role));
return {
...team,
roles: mappedRoles,
};
});
}, [legacyResponse, teamsRolesResponse]);
return {
...legacyResponse,
isLoading: legacyResponse.isLoading || (rolesEnabled ? teamsRolesResponse.isLoading : false),
data: {
teams: teamsWithRoles,
totalCount: legacyResponse.data?.totalCount,
},
};
};
/**
* Get a single team by UID
*/
export const useGetTeam = ({ uid }: { uid: string }) => {
const response = useGetTeamByIdQuery({ teamId: uid, accesscontrol: true });
const dispatch = useDispatch();
// TODO: Eventually remove and handle nav index logic elsewhere
useEffect(() => {
if (response.data) {
dispatch(updateNavIndex(buildNavModel(response.data)));
}
}, [response.data, dispatch]);
return response;
};
/**
* Update a team by UID
*/
export const useUpdateTeam = () => {
const [updateTeam, response] = useUpdateTeamMutation();
const trigger = async ({ uid, team }: { uid: string; team: UpdateTeamCommand }) => {
const mutationResult = await updateTeam({
teamId: uid,
updateTeamCommand: team,
});
return mutationResult;
};
return [trigger, response] as const;
};
/**
* Delete a team by UID
*/
export const useDeleteTeam = () => {
const [deleteTeam, response] = useDeleteTeamByIdMutation();
return [({ uid }: { uid: string }) => deleteTeam({ teamId: uid }), response] as const;
};
/**
* Create a new team, and link any pending roles
*/
export const useCreateTeam = () => {
const [createTeam, response] = useCreateTeamMutation();
const [setTeamRoles] = useSetTeamRolesMutation();
const trigger = async (team: CreateTeamCommand, pendingRoles?: Role[]) => {
const mutationResult = await createTeam({
createTeamCommand: team,
});
const { data } = mutationResult;
if (data && data.teamId && pendingRoles && pendingRoles.length) {
await contextSrv.fetchUserPermissions();
if (contextSrv.licensedAccessControlEnabled() && canUpdateRoles()) {
await setTeamRoles({
teamId: data.teamId,
setTeamRolesCommand: {
roleUids: pendingRoles.map((role) => role.uid),
},
});
}
}
return mutationResult;
};
return [trigger, response] as const;
};
+11 -119
View File
@@ -1,134 +1,26 @@
import { debounce } from 'lodash';
import { getBackendSrv } from '@grafana/runtime';
import { FetchDataArgs } from '@grafana/ui';
import { updateNavIndex } from 'app/core/actions';
import { contextSrv } from 'app/core/services/context_srv';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { AccessControlAction } from 'app/types/accessControl';
import { ThunkResult } from 'app/types/store';
import { Team, TeamWithRoles } from 'app/types/teams';
import { buildNavModel } from './navModel';
import {
teamGroupsLoaded,
queryChanged,
pageChanged,
teamLoaded,
teamsLoaded,
sortChanged,
rolesFetchBegin,
rolesFetchEnd,
} from './reducers';
import { teamGroupsLoaded } from './reducers';
export function loadTeams(initial = false): ThunkResult<void> {
return async (dispatch, getState) => {
const { query, page, perPage, sort } = getState().teams;
// Early return if the user cannot list teams
if (!contextSrv.hasPermission(AccessControlAction.ActionTeamsRead)) {
dispatch(teamsLoaded({ teams: [], totalCount: 0, page: 1, perPage, noTeams: true }));
return;
}
const response = await getBackendSrv().get(
'/api/teams/search',
accessControlQueryParam({ query, page, perpage: perPage, sort })
);
// We only want to check if there is no teams on the initial request.
// A query that returns no teams should not render the empty list banner.
let noTeams = false;
if (initial) {
noTeams = response.teams.length === 0;
}
if (
contextSrv.licensedAccessControlEnabled() &&
contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList)
) {
dispatch(rolesFetchBegin());
const teamIds = response?.teams.map((t: TeamWithRoles) => t.id);
const roles = await getBackendSrv().post(`/api/access-control/teams/roles/search`, { teamIds });
response.teams.forEach((t: TeamWithRoles) => {
t.roles = roles ? roles[t.id] || [] : [];
});
dispatch(rolesFetchEnd());
}
dispatch(teamsLoaded({ noTeams, ...response }));
};
}
const loadTeamsWithDebounce = debounce((dispatch) => dispatch(loadTeams()), 500);
export function loadTeam(uid: string): ThunkResult<Promise<void>> {
export function loadTeamGroups(teamUid: string): ThunkResult<void> {
return async (dispatch) => {
const response = await getBackendSrv().get(`/api/teams/${uid}`, accessControlQueryParam());
dispatch(teamLoaded(response));
dispatch(updateNavIndex(buildNavModel(response)));
};
}
export function deleteTeam(uid: string): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().delete(`/api/teams/${uid}`);
// Update users permissions in case they lost teams.read with the deletion
await contextSrv.fetchUserPermissions();
dispatch(loadTeams());
};
}
export function changeQuery(query: string): ThunkResult<void> {
return async (dispatch) => {
dispatch(queryChanged(query));
loadTeamsWithDebounce(dispatch);
};
}
export function changePage(page: number): ThunkResult<void> {
return async (dispatch) => {
dispatch(pageChanged(page));
dispatch(loadTeams());
};
}
export function changeSort({ sortBy }: FetchDataArgs<Team>): ThunkResult<void> {
const sort = sortBy.length ? `${sortBy[0].id}-${sortBy[0].desc ? 'desc' : 'asc'}` : undefined;
return async (dispatch) => {
dispatch(sortChanged(sort));
dispatch(loadTeams());
};
}
export function updateTeam(name: string, email: string): ThunkResult<void> {
return async (dispatch, getStore) => {
const team = getStore().team.team;
await getBackendSrv().put(`/api/teams/${team.uid}`, { name, email });
dispatch(loadTeam(team.uid));
};
}
export function loadTeamGroups(): ThunkResult<void> {
return async (dispatch, getStore) => {
const team = getStore().team.team;
const response = await getBackendSrv().get(`/api/teams/${team.uid}/groups`);
const response = await getBackendSrv().get(`/api/teams/${teamUid}/groups`);
dispatch(teamGroupsLoaded(response));
};
}
export function addTeamGroup(groupId: string): ThunkResult<void> {
return async (dispatch, getStore) => {
const team = getStore().team.team;
await getBackendSrv().post(`/api/teams/${team.uid}/groups`, { groupId: groupId });
dispatch(loadTeamGroups());
export function addTeamGroup(teamUid: string, groupId: string): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().post(`/api/teams/${teamUid}/groups`, { groupId: groupId });
dispatch(loadTeamGroups(teamUid));
};
}
export function removeTeamGroup(groupId: string): ThunkResult<void> {
return async (dispatch, getStore) => {
const team = getStore().team.team;
export function removeTeamGroup(teamUid: string, groupId: string): ThunkResult<void> {
return async (dispatch) => {
// need to use query parameter due to escaped characters in the request
await getBackendSrv().delete(`/api/teams/${team.uid}/groups?groupId=${encodeURIComponent(groupId)}`);
dispatch(loadTeamGroups());
await getBackendSrv().delete(`/api/teams/${teamUid}/groups?groupId=${encodeURIComponent(groupId)}`);
dispatch(loadTeamGroups(teamUid));
};
}
@@ -1,74 +0,0 @@
import { TeamsState, TeamState } from 'app/types/teams';
import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { getMockTeam, getMockTeamGroups } from '../mocks/teamMocks';
import {
initialTeamsState,
initialTeamState,
teamGroupsLoaded,
teamLoaded,
queryChanged,
teamReducer,
teamsLoaded,
teamsReducer,
} from './reducers';
describe('teams reducer', () => {
describe('when teamsLoaded is dispatched', () => {
it('then state should be correct', () => {
reducerTester<TeamsState>()
.givenReducer(teamsReducer, { ...initialTeamsState })
.whenActionIsDispatched(
teamsLoaded({ teams: [getMockTeam()], page: 1, perPage: 30, noTeams: false, totalCount: 100 })
)
.thenStateShouldEqual({
...initialTeamsState,
hasFetched: true,
teams: [getMockTeam()],
noTeams: false,
totalPages: 4,
perPage: 30,
page: 1,
});
});
});
describe('when setSearchQueryAction is dispatched', () => {
it('then state should be correct', () => {
reducerTester<TeamsState>()
.givenReducer(teamsReducer, { ...initialTeamsState })
.whenActionIsDispatched(queryChanged('test'))
.thenStateShouldEqual({
...initialTeamsState,
query: 'test',
});
});
});
});
describe('team reducer', () => {
describe('when loadTeamsAction is dispatched', () => {
it('then state should be correct', () => {
reducerTester<TeamState>()
.givenReducer(teamReducer, { ...initialTeamState })
.whenActionIsDispatched(teamLoaded(getMockTeam()))
.thenStateShouldEqual({
...initialTeamState,
team: getMockTeam(),
});
});
});
describe('when loadTeamGroupsAction is dispatched', () => {
it('then state should be correct', () => {
reducerTester<TeamState>()
.givenReducer(teamReducer, { ...initialTeamState })
.whenActionIsDispatched(teamGroupsLoaded(getMockTeamGroups(1)))
.thenStateShouldEqual({
...initialTeamState,
groups: getMockTeamGroups(1),
});
});
});
});
+2 -58
View File
@@ -1,60 +1,8 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { TeamsState, Team, TeamState, TeamGroup } from 'app/types/teams';
export const initialTeamsState: TeamsState = {
teams: [],
page: 1,
query: '',
perPage: 30,
totalPages: 0,
noTeams: false,
hasFetched: false,
};
type TeamsFetched = {
teams: Team[];
page: number;
perPage: number;
noTeams: boolean;
totalCount: number;
};
const teamsSlice = createSlice({
name: 'teams',
initialState: initialTeamsState,
reducers: {
teamsLoaded: (state, action: PayloadAction<TeamsFetched>): TeamsState => {
const { totalCount, perPage, ...rest } = action.payload;
const totalPages = Math.ceil(totalCount / perPage);
return { ...state, ...rest, totalPages, perPage, hasFetched: true };
},
queryChanged: (state, action: PayloadAction<string>): TeamsState => {
return { ...state, page: 1, query: action.payload };
},
pageChanged: (state, action: PayloadAction<number>): TeamsState => {
return { ...state, page: action.payload };
},
sortChanged: (state, action: PayloadAction<TeamsState['sort']>): TeamsState => {
return { ...state, sort: action.payload, page: 1 };
},
rolesFetchBegin: (state) => {
return { ...state, rolesLoading: true };
},
rolesFetchEnd: (state) => {
return { ...state, rolesLoading: false };
},
},
});
export const { teamsLoaded, queryChanged, pageChanged, sortChanged, rolesFetchBegin, rolesFetchEnd } =
teamsSlice.actions;
export const teamsReducer = teamsSlice.reducer;
import { TeamState, TeamGroup } from 'app/types/teams';
export const initialTeamState: TeamState = {
team: {} as Team,
members: [],
groups: [],
};
@@ -62,20 +10,16 @@ const teamSlice = createSlice({
name: 'team',
initialState: initialTeamState,
reducers: {
teamLoaded: (state, action: PayloadAction<Team>): TeamState => {
return { ...state, team: action.payload };
},
teamGroupsLoaded: (state, action: PayloadAction<TeamGroup[]>): TeamState => {
return { ...state, groups: action.payload };
},
},
});
export const { teamLoaded, teamGroupsLoaded } = teamSlice.actions;
export const { teamGroupsLoaded } = teamSlice.actions;
export const teamReducer = teamSlice.reducer;
export default {
teams: teamsReducer,
team: teamReducer,
};
@@ -1,22 +0,0 @@
import { TeamState } from 'app/types/teams';
import { getMockTeam } from '../mocks/teamMocks';
import { getTeam } from './selectors';
describe('Team selectors', () => {
describe('Get team', () => {
const mockTeam = getMockTeam();
it('should return team if matching with location team', () => {
const mockState: TeamState = {
team: mockTeam,
members: [],
groups: [],
};
const team = getTeam(mockState, 'aaaaaa');
expect(team).toEqual(mockTeam);
});
});
});
+1 -9
View File
@@ -1,11 +1,3 @@
import { Team, TeamState } from 'app/types/teams';
import { TeamState } from 'app/types/teams';
export const getTeamGroups = (state: TeamState) => state.groups;
export const getTeam = (state: TeamState, currentTeamUid: string): Team | null => {
if (state.team.uid === currentTeamUid) {
return state.team;
}
return null;
};
+3 -12
View File
@@ -1,3 +1,5 @@
import { RoleDto } from 'app/api/clients/legacy';
/**
* UserPermission is a map storing permissions in a form of
* {
@@ -174,17 +176,6 @@ export enum AccessControlAction {
MigrationAssistantMigrate = 'migrationassistant:migrate',
}
export interface Role {
uid: string;
name: string;
displayName: string;
export interface Role extends RoleDto {
filteredDisplayName: string; // name to be shown in filtered role list
description: string;
group: string;
global: boolean;
delegatable?: boolean;
mapped?: boolean;
version: number;
created: string;
updated: string;
}
+2 -51
View File
@@ -1,4 +1,4 @@
import { WithAccessControlMetadata } from '@grafana/data';
import { TeamDto as TeamDtoLegacy } from 'app/api/clients/legacy';
import { Role } from './accessControl';
@@ -13,42 +13,7 @@ export interface TeamDTO {
name: string;
}
// This is the team resource with permissions and metadata expanded
export interface Team extends WithAccessControlMetadata {
/**
* Internal id of team
* @deprecated use uid instead
*/
id: number;
/**
* A unique identifier for the team.
*/
uid: string; // Prefer UUID
/**
* AvatarUrl is the team's avatar URL.
*/
avatarUrl?: string;
/**
* Email of the team.
*/
email?: string;
/**
* MemberCount is the number of the team members.
*/
memberCount: number;
/**
* Name of the team.
*/
name: string;
/**
* OrgId is the ID of an organisation the team belongs to.
*/
orgId: number;
/**
* isProvisioned is set if the team has been provisioned from IdP.
*/
isProvisioned: boolean;
}
export type Team = TeamDtoLegacy;
export interface TeamWithRoles extends Team {
/**
@@ -73,20 +38,6 @@ export interface TeamGroup {
teamId: number;
}
export interface TeamsState {
teams: Team[];
page: number;
query: string;
perPage: number;
noTeams: boolean;
totalPages: number;
hasFetched: boolean;
sort?: string;
rolesLoading?: boolean;
}
export interface TeamState {
team: Team;
members: TeamMember[];
groups: TeamGroup[];
}
+2
View File
@@ -13277,6 +13277,7 @@
"create-team": {
"create": "Create",
"description-email": "This is optional and is primarily used for allowing custom team avatars",
"failed-to-create": "Failed to create team",
"label-email": "Email",
"label-name": "Name",
"label-role": "Role"
@@ -13308,6 +13309,7 @@
"title-edit-team": "Edit team",
"tooltip-edit-team": "Edit team"
},
"loading-teams": "Loading teams...",
"new-team": "New Team",
"placeholder-search-teams": "Search teams"
},
+10 -9
View File
@@ -12703,17 +12703,10 @@
},
"UpdateTeamCommand": {
"properties": {
"Email": {
"email": {
"type": "string"
},
"ExternalUID": {
"type": "string"
},
"ID": {
"format": "int64",
"type": "integer"
},
"Name": {
"name": {
"type": "string"
}
},
@@ -24935,6 +24928,14 @@
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "accesscontrol",
"schema": {
"default": false,
"type": "boolean"
}
}
],
"responses": {