Teams: Refactor tests and tidy up reducer (#112850)

This commit is contained in:
Tom Ratcliffe
2025-10-24 14:14:12 +00:00
committed by GitHub
parent bda35611f1
commit 5ffff1e39d
20 changed files with 312 additions and 316 deletions
-5
View File
@@ -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();
});
});
+33 -68
View File
@@ -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);
});
});
+2 -3
View File
@@ -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
+38 -67
View File
@@ -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();
});
});
});
+38 -34
View File
@@ -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'));
});
});
+43 -30
View File
@@ -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>
+1 -19
View File
@@ -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>()
+2 -9
View File
@@ -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: [],
};
-1
View File
@@ -89,5 +89,4 @@ export interface TeamState {
team: Team;
members: TeamMember[];
groups: TeamGroup[];
searchMemberQuery: string;
}
+3 -2
View File
@@ -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"