Teams: Refactor tests and tidy up reducer (#112850)
This commit is contained in:
@@ -3553,11 +3553,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/teams/TeamSettings.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"public/app/features/teams/state/reducers.ts": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { setupMockStarredDashboards } from './starred';
|
||||
import { setupMockTeams } from './teams';
|
||||
|
||||
/**
|
||||
* Reset any stateful fixtures that are used to drive mock handler endpoints
|
||||
*/
|
||||
export const resetFixtures = () => {
|
||||
setupMockStarredDashboards();
|
||||
setupMockTeams();
|
||||
};
|
||||
|
||||
@@ -11,4 +11,4 @@ export const setupMockStarredDashboards = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const mockStarredDashboardsMap = new Map<string, boolean>();
|
||||
export const mockStarredDashboardsMap = new Map<string, boolean>(initialStarredDashboards.map((uid) => [uid, true]));
|
||||
|
||||
@@ -23,3 +23,14 @@ export const MOCK_TEAMS = [
|
||||
status: {},
|
||||
},
|
||||
];
|
||||
|
||||
export const setupMockTeams = () => {
|
||||
mockTeamsMap.clear();
|
||||
MOCK_TEAMS.forEach((team) => {
|
||||
mockTeamsMap.set(team.metadata.name, { team, groups: [] });
|
||||
});
|
||||
};
|
||||
|
||||
export const mockTeamsMap = new Map<string, { team: (typeof MOCK_TEAMS)[number]; groups: Array<{ groupId: string }> }>(
|
||||
MOCK_TEAMS.map((team) => [team.metadata.name, { team, groups: [] }])
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { HttpResponse, http } from 'msw';
|
||||
|
||||
import { MOCK_TEAMS } from '../../../fixtures/teams';
|
||||
import { MOCK_TEAMS, mockTeamsMap } from '../../../fixtures/teams';
|
||||
|
||||
const k8sTeamToLegacyTeam = (k8sTeam: (typeof MOCK_TEAMS)[number]) => {
|
||||
const k8sTeamToLegacyTeam = (k8sTeam: (typeof MOCK_TEAMS)[number], addAccessControl?: boolean) => {
|
||||
return {
|
||||
name: k8sTeam.spec.title,
|
||||
email: k8sTeam.spec.email,
|
||||
@@ -14,19 +14,98 @@ const k8sTeamToLegacyTeam = (k8sTeam: (typeof MOCK_TEAMS)[number]) => {
|
||||
avatarUrl: '',
|
||||
memberCount: 0,
|
||||
permission: 0,
|
||||
accessControl: addAccessControl ? mockAccessControl : null,
|
||||
};
|
||||
};
|
||||
|
||||
const mockAccessControl = {
|
||||
'teams.permissions:read': true,
|
||||
'teams.permissions:write': true,
|
||||
'teams:delete': true,
|
||||
'teams:read': true,
|
||||
'teams:write': true,
|
||||
};
|
||||
|
||||
const getTeamHandler = () =>
|
||||
http.get<{ uid: string }>('/api/teams/:uid', async ({ params, request }) => {
|
||||
const teamData = mockTeamsMap.get(params.uid);
|
||||
const url = new URL(request.url);
|
||||
const accessControl = url.searchParams.get('accesscontrol') === 'true';
|
||||
|
||||
if (!teamData) {
|
||||
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const response = k8sTeamToLegacyTeam(teamData.team, accessControl);
|
||||
|
||||
return HttpResponse.json(response);
|
||||
});
|
||||
|
||||
const deleteTeamHandler = () =>
|
||||
http.delete<{ uid: string }>('/api/teams/:uid', async ({ params }) => {
|
||||
const teamData = mockTeamsMap.get(params.uid);
|
||||
|
||||
if (!teamData) {
|
||||
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
mockTeamsMap.delete(params.uid);
|
||||
|
||||
return HttpResponse.json({ message: 'Team deleted' });
|
||||
});
|
||||
|
||||
const teamsPreferencesHandler = () =>
|
||||
http.get<{ uid: string }>('/api/teams/:uid/preferences', async ({ params }) => {
|
||||
const team = Array.from(mockTeamsMap.values()).find(
|
||||
(teamData) => teamData.team.metadata.labels['grafana.app/deprecatedInternalID'] === params.uid
|
||||
);
|
||||
|
||||
if (!team) {
|
||||
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// TODO: Mock preferences data
|
||||
return HttpResponse.json({});
|
||||
});
|
||||
|
||||
const teamsGroupsHandler = () =>
|
||||
http.get<{ uid: string }>('/api/teams/:uid/groups', async ({ params }) => {
|
||||
const team = mockTeamsMap.get(params.uid);
|
||||
|
||||
if (!team) {
|
||||
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(team.groups);
|
||||
});
|
||||
|
||||
const teamsUpdateGroupsHandler = () =>
|
||||
http.post<{ uid: string }, { groupId: string }>('/api/teams/:uid/groups', 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,
|
||||
groups: [...teamData.groups, body],
|
||||
};
|
||||
mockTeamsMap.set(params.uid, updatedTeam);
|
||||
|
||||
return HttpResponse.json({ message: 'Group added to Team' });
|
||||
});
|
||||
|
||||
const searchTeamsHandler = () =>
|
||||
http.get('/api/teams/search', async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const accessControl = url.searchParams.get('accesscontrol') === 'true';
|
||||
// TODO in future: pagination and mock querying
|
||||
const page = url.searchParams.get('page') ?? 1;
|
||||
const perPage = url.searchParams.get('perPage') ?? 1000;
|
||||
|
||||
return HttpResponse.json({
|
||||
totalCount: MOCK_TEAMS.length,
|
||||
teams: MOCK_TEAMS.map(k8sTeamToLegacyTeam),
|
||||
totalCount: mockTeamsMap.size,
|
||||
teams: Array.from(mockTeamsMap.values()).map((t) => k8sTeamToLegacyTeam(t.team, accessControl)),
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
@@ -40,7 +119,7 @@ const createTeamHandler = () =>
|
||||
return HttpResponse.json({ message: 'bad request data' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingTeam = MOCK_TEAMS.find((t) => t.spec.title === body.name);
|
||||
const existingTeam = Array.from(mockTeamsMap.values()).find((teamData) => teamData.team.spec.title === body.name);
|
||||
|
||||
if (existingTeam) {
|
||||
return HttpResponse.json({ message: 'Team name taken' }, { status: 409 });
|
||||
@@ -48,6 +127,14 @@ const createTeamHandler = () =>
|
||||
return HttpResponse.json({ message: 'Team created', teamId: 10, uid: 'aethyfifmhwcgd' }, { status: 200 });
|
||||
});
|
||||
|
||||
const handlers = [searchTeamsHandler(), createTeamHandler()];
|
||||
const handlers = [
|
||||
teamsPreferencesHandler(),
|
||||
teamsGroupsHandler(),
|
||||
teamsUpdateGroupsHandler(),
|
||||
searchTeamsHandler(),
|
||||
getTeamHandler(),
|
||||
deleteTeamHandler(),
|
||||
createTeamHandler(),
|
||||
];
|
||||
|
||||
export default handlers;
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('SharedPreferences', () => {
|
||||
await selectComboboxOptionInTest(await screen.findByRole('combobox', { name: 'Week start' }), 'Saturday');
|
||||
await selectComboboxOptionInTest(await screen.findByRole('combobox', { name: /language/i }), 'Français');
|
||||
|
||||
await user.click(screen.getByText('Save'));
|
||||
await user.click(screen.getByText('Save preferences'));
|
||||
|
||||
const requests = await capture;
|
||||
const newPreferences = await getPrefsUpdateRequest(requests);
|
||||
@@ -154,7 +154,7 @@ describe('SharedPreferences', () => {
|
||||
|
||||
await selectComboboxOptionInTest(screen.getByRole('combobox', { name: /language/i }), 'Default');
|
||||
|
||||
await user.click(screen.getByText('Save'));
|
||||
await user.click(screen.getByText('Save preferences'));
|
||||
const requests = await capture;
|
||||
const newPreferences = await getPrefsUpdateRequest(requests);
|
||||
expect(newPreferences).toEqual({
|
||||
@@ -171,7 +171,7 @@ describe('SharedPreferences', () => {
|
||||
|
||||
it('refreshes the page after saving preferences', async () => {
|
||||
const { user } = await setup();
|
||||
await user.click(screen.getByText('Save'));
|
||||
await user.click(screen.getByText('Save preferences'));
|
||||
expect(mockReload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,7 +367,7 @@ export class SharedPreferences extends PureComponent<Props, State> {
|
||||
variant="primary"
|
||||
data-testid={selectors.components.UserProfile.preferencesSaveButton}
|
||||
>
|
||||
<Trans i18nKey="common.save">Save</Trans>
|
||||
<Trans i18nKey="shared-preferences.save">Save preferences</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mockToolkitActionCreator } from 'test/core/redux/mocks';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
import { render, screen } from 'test/test-utils';
|
||||
|
||||
import { NavModel } from '@grafana/data';
|
||||
import { ModalRoot } from '@grafana/ui';
|
||||
import { Organization } from 'app/types/organization';
|
||||
|
||||
import { backendSrv } from '../../core/services/backend_srv';
|
||||
@@ -46,10 +45,11 @@ const setup = (propOverrides?: object) => {
|
||||
};
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
return render(
|
||||
<>
|
||||
<OrgDetailsPage {...props} />
|
||||
</TestProvider>
|
||||
<ModalRoot />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('Render', () => {
|
||||
});
|
||||
|
||||
it('should show a modal when submitting', async () => {
|
||||
setup({
|
||||
const { user } = setup({
|
||||
organization: {
|
||||
name: 'Cool org',
|
||||
id: 1,
|
||||
@@ -99,8 +99,8 @@ describe('Render', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Save preferences' }));
|
||||
|
||||
expect(screen.getByText('Confirm preferences update')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Confirm preferences update')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from 'test/test-utils';
|
||||
|
||||
import { TeamGroup } from 'app/types/teams';
|
||||
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 { Props, TeamGroupSync } from './TeamGroupSync';
|
||||
import TeamGroupSync from './TeamGroupSync';
|
||||
import { getMockTeamGroups } from './mocks/teamMocks';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
isReadOnly: false,
|
||||
groups: [] as TeamGroup[],
|
||||
loadTeamGroups: jest.fn(),
|
||||
addTeamGroup: jest.fn(),
|
||||
removeTeamGroup: jest.fn(),
|
||||
};
|
||||
setBackendSrv(backendSrv);
|
||||
setupMockServer();
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
return render(<TeamGroupSync {...props} />);
|
||||
const setup = (preloadedTeamState?: Partial<TeamState>) => {
|
||||
return render(<TeamGroupSync isReadOnly={false} />, {
|
||||
preloadedState: {
|
||||
team: {
|
||||
members: [],
|
||||
groups: [],
|
||||
team: { uid: MOCK_TEAMS[0].metadata.name } as Team,
|
||||
...preloadedTeamState,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('TeamGroupSync', () => {
|
||||
@@ -32,26 +37,22 @@ describe('TeamGroupSync', () => {
|
||||
});
|
||||
|
||||
it('should call add group', async () => {
|
||||
const mockAddGroup = jest.fn();
|
||||
setup({ addTeamGroup: mockAddGroup });
|
||||
const { user } = setup();
|
||||
// Empty List CTA "Add group" button is second in the DOM order
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /add group/i })[1]);
|
||||
await user.click(screen.getAllByRole('button', { name: /add group/i })[1]);
|
||||
expect(screen.getByRole('textbox', { name: /add external group/i })).toBeVisible();
|
||||
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /add external group/i }), 'test/group');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /add group/i })[0]);
|
||||
await waitFor(() => {
|
||||
expect(mockAddGroup).toHaveBeenCalledWith('test/group');
|
||||
});
|
||||
await user.type(screen.getByRole('textbox', { name: /add external group/i }), 'test/group');
|
||||
await user.click(screen.getAllByRole('button', { name: /add group/i })[0]);
|
||||
|
||||
expect(screen.getByRole('row', { name: /test\/group/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call remove group', async () => {
|
||||
const mockRemoveGroup = jest.fn();
|
||||
it('should remove group', async () => {
|
||||
const mockGroup: TeamGroup = { teamId: 1, groupId: 'someGroup' };
|
||||
setup({ removeTeamGroup: mockRemoveGroup, groups: [mockGroup] });
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Remove group someGroup' }));
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveGroup).toHaveBeenCalledWith('someGroup');
|
||||
});
|
||||
const { user } = setup({ groups: [mockGroup] });
|
||||
await user.click(screen.getByRole('button', { name: 'Remove group someGroup' }));
|
||||
|
||||
expect(screen.queryByRole('row', { name: /test\/group/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,63 +1,44 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
import { render, screen, waitFor } 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 { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { Team } from 'app/types/teams';
|
||||
|
||||
import { Props, TeamList } from './TeamList';
|
||||
import { getMockTeam, getMultipleMockTeams } from './mocks/teamMocks';
|
||||
import TeamList from './TeamList';
|
||||
|
||||
jest.mock('app/core/core', () => ({
|
||||
contextSrv: {
|
||||
hasPermission: (action: string) => true,
|
||||
licensedAccessControlEnabled: () => false,
|
||||
user: {
|
||||
helpFlags1: 0,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
teams: [] as Team[],
|
||||
noTeams: false,
|
||||
loadTeams: jest.fn(),
|
||||
deleteTeam: jest.fn(),
|
||||
changePage: jest.fn(),
|
||||
changeQuery: jest.fn(),
|
||||
changeSort: jest.fn(),
|
||||
query: '',
|
||||
totalPages: 0,
|
||||
page: 0,
|
||||
hasFetched: false,
|
||||
perPage: 10,
|
||||
rolesLoading: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TeamList {...props} />
|
||||
</TestProvider>
|
||||
);
|
||||
};
|
||||
setBackendSrv(backendSrv);
|
||||
setupMockServer();
|
||||
|
||||
describe('TeamList', () => {
|
||||
it('should render teams table', () => {
|
||||
setup({ teams: getMultipleMockTeams(5), teamsCount: 5, hasFetched: true });
|
||||
expect(screen.getAllByRole('row')).toHaveLength(6); // 5 teams plus table header row
|
||||
beforeEach(() => {
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
|
||||
jest.spyOn(contextSrv, 'hasPermissionInMetadata').mockReturnValue(true);
|
||||
jest.spyOn(contextSrv, 'fetchUserPermissions').mockResolvedValue();
|
||||
});
|
||||
|
||||
it('should render teams table', async () => {
|
||||
render(<TeamList />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByRole('row'))
|
||||
// Number of teams plus table header row
|
||||
.toHaveLength(MOCK_TEAMS.length + 1)
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes a team', async () => {
|
||||
const mockTeam = MOCK_TEAMS[0];
|
||||
const { user } = render(<TeamList />);
|
||||
await user.click(await screen.findByRole('button', { name: `Delete team ${mockTeam.spec.title}` }));
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(mockTeam.spec.title)).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
describe('when user has access to create a team', () => {
|
||||
it('should enable the new team button', () => {
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
|
||||
setup({
|
||||
teams: getMultipleMockTeams(1),
|
||||
totalCount: 1,
|
||||
hasFetched: true,
|
||||
});
|
||||
it('should enable the new team button', async () => {
|
||||
render(<TeamList />);
|
||||
|
||||
expect(screen.getByRole('link', { name: /new team/i })).not.toHaveStyle('pointer-events: none');
|
||||
});
|
||||
@@ -66,25 +47,9 @@ describe('TeamList', () => {
|
||||
describe('when user does not have access to create a team', () => {
|
||||
it('should disable the new team button', () => {
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
|
||||
setup({
|
||||
teams: getMultipleMockTeams(1),
|
||||
totalCount: 1,
|
||||
hasFetched: true,
|
||||
});
|
||||
render(<TeamList />);
|
||||
|
||||
expect(screen.getByRole('link', { name: /new team/i })).toHaveStyle('pointer-events: none');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call delete team', async () => {
|
||||
const mockDelete = jest.fn();
|
||||
const mockTeam = getMockTeam();
|
||||
jest.spyOn(contextSrv, 'hasPermissionInMetadata').mockReturnValue(true);
|
||||
setup({ deleteTeam: mockDelete, teams: [mockTeam], totalCount: 1, hasFetched: true });
|
||||
await userEvent.click(screen.getByRole('button', { name: `Delete team ${mockTeam.name}` }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalledWith(mockTeam.uid);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import {
|
||||
Avatar,
|
||||
@@ -50,7 +49,7 @@ const skeletonData: TeamWithRoles[] = new Array(3).fill(null).map((_, index) =>
|
||||
isProvisioned: false,
|
||||
}));
|
||||
|
||||
export const TeamList = ({
|
||||
const TeamList = ({
|
||||
teams,
|
||||
query,
|
||||
noTeams,
|
||||
@@ -328,7 +327,7 @@ const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||
export default connector(TeamList);
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
const getStyles = () => ({
|
||||
blockSkeleton: css({
|
||||
lineHeight: 1,
|
||||
// needed for things to align properly in the table
|
||||
|
||||
@@ -1,70 +1,25 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
import { render } from 'test/test-utils';
|
||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||
import { screen, render, testWithLicenseFeatures, waitFor } 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 { backendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
import TeamPages from './TeamPages';
|
||||
import { getMockTeam } from './mocks/teamMocks';
|
||||
|
||||
jest.mock('app/core/components/Select/UserPicker', () => {
|
||||
return { UserPicker: () => null };
|
||||
});
|
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({
|
||||
contextSrv: {
|
||||
accessControlEnabled: () => true,
|
||||
hasPermissionInMetadata: () => true,
|
||||
user: {},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getBackendSrv: () => ({
|
||||
get: jest.fn().mockResolvedValue(getMockTeam()),
|
||||
}),
|
||||
config: {
|
||||
...jest.requireActual('@grafana/runtime').config,
|
||||
licenseInfo: {
|
||||
enabledFeatures: { teamsync: true },
|
||||
stateInfo: '',
|
||||
licenseUrl: '',
|
||||
},
|
||||
featureToggles: { accesscontrol: true },
|
||||
buildInfo: {
|
||||
edition: 'Open Source',
|
||||
version: '7.5.0',
|
||||
commit: 'abc123',
|
||||
env: 'production',
|
||||
latestVersion: '',
|
||||
hasUpdate: false,
|
||||
hideVersion: false,
|
||||
},
|
||||
appSubUrl: '',
|
||||
},
|
||||
featureEnabled: () => true,
|
||||
}));
|
||||
|
||||
// Mock connected child components instead of rendering them
|
||||
jest.mock('./TeamSettings', () => {
|
||||
//eslint-disable-next-line
|
||||
return () => <div>Team settings</div>;
|
||||
});
|
||||
|
||||
jest.mock('./TeamGroupSync', () => {
|
||||
//eslint-disable-next-line
|
||||
return () => <div>Team group sync</div>;
|
||||
});
|
||||
|
||||
jest.mock('react-router-dom-v5-compat', () => ({
|
||||
...jest.requireActual('react-router-dom-v5-compat'),
|
||||
useParams: jest.fn(),
|
||||
}));
|
||||
setBackendSrv(backendSrv);
|
||||
setupMockServer();
|
||||
|
||||
const setup = (propOverrides: { teamUid?: string; pageName?: string } = {}) => {
|
||||
const pageName = propOverrides.pageName ?? 'members';
|
||||
const teamUid = propOverrides.teamUid ?? 'aaaaaa';
|
||||
(useParams as jest.Mock).mockReturnValue({ uid: `${teamUid}`, page: pageName });
|
||||
render(<TeamPages />);
|
||||
const teamUid = propOverrides.teamUid ?? MOCK_TEAMS[0].metadata.name;
|
||||
render(
|
||||
<Routes>
|
||||
<Route path="/org/teams/:uid/:page" element={<TeamPages />} />
|
||||
</Routes>,
|
||||
{ historyOptions: { initialEntries: [`/org/teams/${teamUid}/${pageName}`] } }
|
||||
);
|
||||
};
|
||||
|
||||
describe('TeamPages', () => {
|
||||
@@ -73,14 +28,30 @@ describe('TeamPages', () => {
|
||||
pageName: 'settings',
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Team settings')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Team details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render group sync page', async () => {
|
||||
setup({
|
||||
pageName: 'groupsync',
|
||||
});
|
||||
describe('teamsync feature disabled', () => {
|
||||
testWithLicenseFeatures({ disable: ['teamsync'] });
|
||||
|
||||
expect(await screen.findByText('Team group sync')).toBeInTheDocument();
|
||||
it('should not render group sync page', async () => {
|
||||
setup({
|
||||
pageName: 'groupsync',
|
||||
});
|
||||
await waitFor(() => expect(screen.queryAllByText(/loading .../i)).toHaveLength(0));
|
||||
|
||||
expect(screen.queryByRole('heading', { name: /external group sync/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('teamsync feature enabled', () => {
|
||||
testWithLicenseFeatures({ enable: ['teamsync'] });
|
||||
|
||||
it('should render group sync page', async () => {
|
||||
setup({
|
||||
pageName: 'groupsync',
|
||||
});
|
||||
expect(await screen.findByRole('heading', { name: /external group sync/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,64 +1,68 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, waitFor } 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 { contextSrv } from 'app/core/core';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
import { Props, TeamSettings } from './TeamSettings';
|
||||
import { getMockTeam } from './mocks/teamMocks';
|
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({
|
||||
contextSrv: {
|
||||
licensedAccessControlEnabled: () => false,
|
||||
hasPermission: () => true,
|
||||
hasPermissionInMetadata: () => true,
|
||||
user: { orgId: 1 },
|
||||
},
|
||||
}));
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockImplementation(() => true);
|
||||
jest.spyOn(contextSrv, 'hasPermissionInMetadata').mockImplementation(() => true);
|
||||
|
||||
jest.mock('app/core/components/SharedPreferences/SharedPreferences', () => {
|
||||
return { SharedPreferences: () => <div /> };
|
||||
});
|
||||
|
||||
jest.mock('app/core/components/RolePicker/hooks', () => ({
|
||||
useRoleOptions: jest.fn().mockReturnValue([{ roleOptions: [] }, jest.fn()]),
|
||||
}));
|
||||
setBackendSrv(backendSrv);
|
||||
setupMockServer();
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const team = MOCK_TEAMS[0];
|
||||
const props: Props = {
|
||||
team: getMockTeam(),
|
||||
team: {
|
||||
id: Number(team.metadata.labels['grafana.app/deprecatedInternalID']),
|
||||
uid: team.metadata.name,
|
||||
memberCount: 0,
|
||||
name: team.spec.title,
|
||||
orgId: 1,
|
||||
isProvisioned: false,
|
||||
},
|
||||
updateTeam: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
render(<TeamSettings {...props} />);
|
||||
return render(<TeamSettings {...props} />);
|
||||
};
|
||||
|
||||
describe('Team settings', () => {
|
||||
it('should render component', () => {
|
||||
it('should render component', async () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByText('Team details')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Team details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
const mockUpdate = jest.fn();
|
||||
setup({ updateTeam: mockUpdate });
|
||||
await userEvent.clear(screen.getByRole('textbox', { name: /Name/ }));
|
||||
await userEvent.type(screen.getByLabelText(/Email/i), 'team@test.com');
|
||||
// Submitting with userEvent doesn't work here
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Save' }));
|
||||
const { user } = setup({ updateTeam: mockUpdate });
|
||||
await screen.findByText('Team details');
|
||||
|
||||
await user.clear(screen.getByRole('textbox', { name: /Name/ }));
|
||||
await user.type(screen.getByLabelText(/Email/i), 'team@test.com');
|
||||
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();
|
||||
setup({ updateTeam: mockUpdate });
|
||||
await userEvent.clear(screen.getByRole('textbox', { name: /Name/ }));
|
||||
await userEvent.clear(screen.getByLabelText(/Email/i));
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /Name/ }), 'New team');
|
||||
await userEvent.type(screen.getByLabelText(/Email/i), 'team@test.com');
|
||||
// Submitting with userEvent doesn't work here
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Save' }));
|
||||
const { user } = setup({ updateTeam: mockUpdate });
|
||||
|
||||
await user.clear(screen.getByRole('textbox', { name: /Name/ }));
|
||||
await user.clear(screen.getByLabelText(/Email/i));
|
||||
await user.type(screen.getByRole('textbox', { name: /Name/ }), 'New team');
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,40 +50,53 @@ export const TeamSettings = ({ team, updateTeam }: Props) => {
|
||||
<Stack direction={'column'} gap={3}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '600px' }}>
|
||||
<FieldSet label={t('teams.team-settings.label-team-details', 'Team details')}>
|
||||
<Field label={t('teams.team-settings.label-numerical-identifier', 'Numerical identifier')} disabled={true}>
|
||||
<Input value={team.id} id="id-input" />
|
||||
</Field>
|
||||
<Field
|
||||
label={t('teams.team-settings.label-name', 'Name')}
|
||||
disabled={!canWriteTeamSettings || !!team.isProvisioned}
|
||||
required
|
||||
invalid={!!errors.name}
|
||||
error="Name is required"
|
||||
>
|
||||
<Input {...register('name', { required: true })} id="name-input" />
|
||||
</Field>
|
||||
|
||||
{contextSrv.licensedAccessControlEnabled() && canListRoles && (
|
||||
<Field label={t('teams.team-settings.label-role', 'Role')}>
|
||||
<TeamRolePicker teamId={team.id} roleOptions={roleOptions} disabled={!canUpdateRoles} maxWidth="100%" />
|
||||
<Stack direction="column" gap={2}>
|
||||
<Field
|
||||
noMargin
|
||||
label={t('teams.team-settings.label-numerical-identifier', 'Numerical identifier')}
|
||||
disabled={true}
|
||||
>
|
||||
<Input value={team.id} id="id-input" />
|
||||
</Field>
|
||||
<Field
|
||||
noMargin
|
||||
label={t('teams.team-settings.label-name', 'Name')}
|
||||
disabled={!canWriteTeamSettings || !!team.isProvisioned}
|
||||
required
|
||||
invalid={!!errors.name}
|
||||
error="Name is required"
|
||||
>
|
||||
<Input {...register('name', { required: true })} id="name-input" />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label={t('teams.team-settings.label-email', 'Email')}
|
||||
description={t(
|
||||
'teams.team-settings.description-email',
|
||||
'This is optional and is primarily used to set the team profile avatar (via gravatar service)'
|
||||
{contextSrv.licensedAccessControlEnabled() && canListRoles && (
|
||||
<Field noMargin label={t('teams.team-settings.label-role', 'Role')}>
|
||||
<TeamRolePicker teamId={team.id} roleOptions={roleOptions} disabled={!canUpdateRoles} maxWidth="100%" />
|
||||
</Field>
|
||||
)}
|
||||
disabled={!canWriteTeamSettings}
|
||||
>
|
||||
{/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */}
|
||||
<Input {...register('email')} placeholder="team@email.com" type="email" id="email-input" />
|
||||
</Field>
|
||||
<Button type="submit" disabled={!canWriteTeamSettings}>
|
||||
<Trans i18nKey="teams.team-settings.save">Save</Trans>
|
||||
</Button>
|
||||
|
||||
<Field
|
||||
noMargin
|
||||
label={t('teams.team-settings.label-email', 'Email')}
|
||||
description={t(
|
||||
'teams.team-settings.description-email',
|
||||
'This is optional and is primarily used to set the team profile avatar (via the Gravatar service)'
|
||||
)}
|
||||
disabled={!canWriteTeamSettings}
|
||||
>
|
||||
<Input
|
||||
{...register('email')}
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="team@email.com"
|
||||
type="email"
|
||||
id="email-input"
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
</FieldSet>
|
||||
<Button type="submit" disabled={!canWriteTeamSettings}>
|
||||
<Trans i18nKey="teams.team-settings.save">Save team details</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
<SharedPreferences resourceUri={`teams/${team.id}`} disabled={!canWriteTeamSettings} preferenceType="team" />
|
||||
</Stack>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { contextSrv } from 'app/core/core';
|
||||
import { accessControlQueryParam } from 'app/core/utils/accessControl';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
import { ThunkResult } from 'app/types/store';
|
||||
import { Team, TeamMember, TeamWithRoles } from 'app/types/teams';
|
||||
import { Team, TeamWithRoles } from 'app/types/teams';
|
||||
|
||||
import { buildNavModel } from './navModel';
|
||||
import {
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
queryChanged,
|
||||
pageChanged,
|
||||
teamLoaded,
|
||||
teamMembersLoaded,
|
||||
teamsLoaded,
|
||||
sortChanged,
|
||||
rolesFetchBegin,
|
||||
@@ -101,14 +100,6 @@ export function changeSort({ sortBy }: FetchDataArgs<Team>): ThunkResult<void> {
|
||||
};
|
||||
}
|
||||
|
||||
export function loadTeamMembers(): ThunkResult<void> {
|
||||
return async (dispatch, getStore) => {
|
||||
const team = getStore().team.team;
|
||||
const response = await getBackendSrv().get(`/api/teams/${team.uid}/members`);
|
||||
dispatch(teamMembersLoaded(response));
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTeam(name: string, email: string): ThunkResult<void> {
|
||||
return async (dispatch, getStore) => {
|
||||
const team = getStore().team.team;
|
||||
@@ -141,12 +132,3 @@ export function removeTeamGroup(groupId: string): ThunkResult<void> {
|
||||
dispatch(loadTeamGroups());
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTeamMember(member: TeamMember): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
await getBackendSrv().put(`/api/teams/${member.teamId}/members/${member.userId}`, {
|
||||
permission: member.permission,
|
||||
});
|
||||
dispatch(loadTeamMembers());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { TeamsState, TeamState } from 'app/types/teams';
|
||||
|
||||
import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||
import { getMockTeam, getMockTeamGroups, getMockTeamMember } from '../mocks/teamMocks';
|
||||
import { getMockTeam, getMockTeamGroups } from '../mocks/teamMocks';
|
||||
|
||||
import {
|
||||
initialTeamsState,
|
||||
initialTeamState,
|
||||
setSearchMemberQuery,
|
||||
teamGroupsLoaded,
|
||||
teamLoaded,
|
||||
queryChanged,
|
||||
teamMembersLoaded,
|
||||
teamReducer,
|
||||
teamsLoaded,
|
||||
teamsReducer,
|
||||
@@ -62,30 +60,6 @@ describe('team reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('when loadTeamMembersAction is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<TeamState>()
|
||||
.givenReducer(teamReducer, { ...initialTeamState })
|
||||
.whenActionIsDispatched(teamMembersLoaded([getMockTeamMember()]))
|
||||
.thenStateShouldEqual({
|
||||
...initialTeamState,
|
||||
members: [getMockTeamMember()],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setSearchMemberQueryAction is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<TeamState>()
|
||||
.givenReducer(teamReducer, { ...initialTeamState })
|
||||
.whenActionIsDispatched(setSearchMemberQuery('member'))
|
||||
.thenStateShouldEqual({
|
||||
...initialTeamState,
|
||||
searchMemberQuery: 'member',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when loadTeamGroupsAction is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<TeamState>()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { TeamsState, Team, TeamState, TeamMember, TeamGroup } from 'app/types/teams';
|
||||
import { TeamsState, Team, TeamState, TeamGroup } from 'app/types/teams';
|
||||
|
||||
export const initialTeamsState: TeamsState = {
|
||||
teams: [],
|
||||
@@ -56,7 +56,6 @@ export const initialTeamState: TeamState = {
|
||||
team: {} as Team,
|
||||
members: [],
|
||||
groups: [],
|
||||
searchMemberQuery: '',
|
||||
};
|
||||
|
||||
const teamSlice = createSlice({
|
||||
@@ -66,19 +65,13 @@ const teamSlice = createSlice({
|
||||
teamLoaded: (state, action: PayloadAction<Team>): TeamState => {
|
||||
return { ...state, team: action.payload };
|
||||
},
|
||||
teamMembersLoaded: (state, action: PayloadAction<TeamMember[]>): TeamState => {
|
||||
return { ...state, members: action.payload };
|
||||
},
|
||||
setSearchMemberQuery: (state, action: PayloadAction<string>): TeamState => {
|
||||
return { ...state, searchMemberQuery: action.payload };
|
||||
},
|
||||
teamGroupsLoaded: (state, action: PayloadAction<TeamGroup[]>): TeamState => {
|
||||
return { ...state, groups: action.payload };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { teamLoaded, teamGroupsLoaded, teamMembersLoaded, setSearchMemberQuery } = teamSlice.actions;
|
||||
export const { teamLoaded, teamGroupsLoaded } = teamSlice.actions;
|
||||
|
||||
export const teamReducer = teamSlice.reducer;
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ describe('Team selectors', () => {
|
||||
it('should return team if matching with location team', () => {
|
||||
const mockState: TeamState = {
|
||||
team: mockTeam,
|
||||
searchMemberQuery: '',
|
||||
members: [],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
@@ -89,5 +89,4 @@ export interface TeamState {
|
||||
team: Team;
|
||||
members: TeamMember[];
|
||||
groups: TeamGroup[];
|
||||
searchMemberQuery: string;
|
||||
}
|
||||
|
||||
@@ -12678,6 +12678,7 @@
|
||||
"theme-label": "Interface theme",
|
||||
"week-start-label": "Week start"
|
||||
},
|
||||
"save": "Save preferences",
|
||||
"theme": {
|
||||
"default-label": "Default",
|
||||
"experimental": "Experimental"
|
||||
@@ -13079,13 +13080,13 @@
|
||||
"placeholder-search-teams": "Search teams"
|
||||
},
|
||||
"team-settings": {
|
||||
"description-email": "This is optional and is primarily used to set the team profile avatar (via gravatar service)",
|
||||
"description-email": "This is optional and is primarily used to set the team profile avatar (via the Gravatar service)",
|
||||
"label-email": "Email",
|
||||
"label-name": "Name",
|
||||
"label-numerical-identifier": "Numerical identifier",
|
||||
"label-role": "Role",
|
||||
"label-team-details": "Team details",
|
||||
"save": "Save"
|
||||
"save": "Save team details"
|
||||
},
|
||||
"team-sync-upgrade-content": {
|
||||
"description": "Team Sync makes it easier for you to manage users' access in Grafana, by immediately updating each user's Grafana teams and permissions based on their single sign-on group membership, instead of when users sign in"
|
||||
|
||||
Reference in New Issue
Block a user