Teams: Refactor most functionality to use hooks (#113713)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-9
@@ -8744,17 +8744,10 @@
|
||||
"UpdateTeamCommand": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Email": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"ExternalUID": {
|
||||
"type": "string"
|
||||
},
|
||||
"ID": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"Name": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+8
-9
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+10
-9
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user