Teams: Tidy up CreateTeam tests and add fixtures/handlers (#108978)

* Add fixtures and mock handlers for legacy teams API

* Fix teams betterer issues and error handling

* Refactor CreateTeams tests

* Update .betterer.results
This commit is contained in:
Tom Ratcliffe
2025-07-31 16:01:43 +01:00
committed by GitHub
parent 0ca2843fa3
commit 4a0758bae5
7 changed files with 176 additions and 93 deletions
-5
View File
@@ -2752,11 +2752,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Using localeCompare() can cause performance issues when sorting large datasets. Consider using Intl.Collator for better performance when sorting arrays, or add an eslint-disable comment if sorting a small, known dataset.", "1"]
],
"public/app/features/teams/CreateTeam.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"]
],
"public/app/features/teams/TeamSettings.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"],
@@ -0,0 +1,25 @@
import { Chance } from 'chance';
const chance = new Chance('mock-teams');
export const MOCK_TEAMS = [
{
metadata: {
name: chance.string({ length: 14, pool: 'abcdefghijklmnopqrstuvwxyz1234567890' }),
namespace: 'default',
resourceVersion: '1737038862000',
creationTimestamp: '2025-01-16T14:47:42Z',
labels: {
'grafana.app/deprecatedInternalID': chance.integer({ min: 1, max: 1000 }).toString(),
},
annotations: {
'grafana.app/updatedTimestamp': '2025-01-16T14:47:42Z',
},
},
spec: {
title: 'Test Team',
email: 'foo@example.com',
},
status: {},
},
];
@@ -1,5 +1,7 @@
import { HttpHandler } from 'msw';
const allHandlers: HttpHandler[] = [];
import teamsHandlers from './api/teams/handlers';
const allHandlers: HttpHandler[] = [...teamsHandlers];
export default allHandlers;
@@ -0,0 +1,53 @@
import { HttpResponse, http } from 'msw';
import { MOCK_TEAMS } from '../../../fixtures/teams';
const k8sTeamToLegacyTeam = (k8sTeam: (typeof MOCK_TEAMS)[number]) => {
return {
name: k8sTeam.spec.title,
email: k8sTeam.spec.email,
id: Number(k8sTeam.metadata.labels['grafana.app/deprecatedInternalID']),
uid: k8sTeam.metadata.name,
orgId: 1,
externalUID: '',
isProvisioned: false,
avatarUrl: '',
memberCount: 0,
permission: 0,
};
};
const searchTeamsHandler = () =>
http.get('/api/teams/search', async ({ request }) => {
const url = new URL(request.url);
// 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),
page,
perPage,
});
});
const createTeamHandler = () =>
http.post<never, { name: string; email: string }>('/api/teams', async ({ request }) => {
const body = await request.json();
if (!body.name) {
return HttpResponse.json({ message: 'bad request data' }, { status: 400 });
}
const existingTeam = MOCK_TEAMS.find((t) => t.spec.title === body.name);
if (existingTeam) {
return HttpResponse.json({ message: 'Team name taken' }, { status: 409 });
}
return HttpResponse.json({ message: 'Team created', teamId: 10, uid: 'aethyfifmhwcgd' }, { status: 200 });
});
const handlers = [searchTeamsHandler(), createTeamHandler()];
export default handlers;
+1 -1
View File
@@ -1 +1 @@
export {};
export { MOCK_TEAMS } from './fixtures/teams';
+54 -49
View File
@@ -1,70 +1,75 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestProvider } from 'test/helpers/TestProvider';
import { UserEvent } from '@testing-library/user-event';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { render, screen, waitFor } from 'test/test-utils';
import { BackendSrv, setBackendSrv } from '@grafana/runtime';
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 { CreateTeam } from './CreateTeam';
beforeEach(() => {
jest.clearAllMocks();
});
setBackendSrv(backendSrv);
setupMockServer();
jest.mock('app/core/core', () => ({
contextSrv: {
licensedAccessControlEnabled: () => false,
hasPermission: () => true,
hasPermissionInMetadata: () => true,
user: { orgId: 1 },
},
}));
jest.mock('app/core/components/RolePicker/hooks', () => ({
useRoleOptions: jest.fn().mockReturnValue([{ roleOptions: [] }, jest.fn()]),
}));
const mockPost = jest.fn(() => {
return Promise.resolve({});
});
setBackendSrv({
post: mockPost,
} as unknown as BackendSrv);
const setup = () => {
return render(
<TestProvider>
<CreateTeam />
</TestProvider>
const setup = async () => {
const view = render(
<Routes>
<Route path="/org/teams/create" element={<CreateTeam />} />
<Route path="/org/teams/edit/:id" element={<div>Edit team page</div>} />
</Routes>,
{
historyOptions: { initialEntries: ['/org/teams/create'] },
}
);
await waitFor(async () => expect(screen.queryAllByTestId('Spinner')).toHaveLength(0));
return view;
};
const attemptCreateTeam = async (user: UserEvent, teamName?: string, teamEmail?: string) => {
teamName && (await user.type(screen.getByRole('textbox', { name: /name/i }), teamName));
teamEmail && (await user.type(screen.getByLabelText(/email/i), teamEmail));
await user.click(screen.getByRole('button', { name: /create/i }));
};
describe('Create team', () => {
it('should render component', () => {
setup();
beforeEach(() => {
contextSrv.licensedAccessControlEnabled = () => false;
contextSrv.hasPermission = () => true;
contextSrv.hasPermissionInMetadata = () => true;
contextSrv.fetchUserPermissions = () => Promise.resolve();
});
it('should render component', async () => {
await setup();
await waitFor(async () => expect(screen.queryAllByTestId('Spinner')).toHaveLength(0));
expect(screen.getByRole('textbox', { name: /name/i })).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should send correct data to the server', async () => {
setup();
await userEvent.type(screen.getByRole('textbox', { name: /name/i }), 'Test team');
await userEvent.type(screen.getByLabelText(/email/i), 'team@test.com');
await userEvent.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(expect.anything(), { name: 'Test team', email: 'team@test.com' });
});
const { user } = await setup();
await attemptCreateTeam(user, 'Test team', 'team@test.com');
expect(await screen.findByText(/edit team page/i)).toBeInTheDocument();
});
it('should validate required fields', async () => {
setup();
await userEvent.type(screen.getByLabelText(/email/i), 'team@test.com');
await userEvent.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => {
expect(mockPost).not.toBeCalled();
});
expect(screen.getAllByRole('alert')).toHaveLength(1);
const { user } = await setup();
await attemptCreateTeam(user, undefined, 'team@test.com');
expect(await screen.findAllByRole('alert')).toHaveLength(1);
expect(screen.getByText(/team name is required/i)).toBeInTheDocument();
expect(screen.queryByText(/edit team page/i)).not.toBeInTheDocument();
});
it('prevents creation of duplicate team name', async () => {
jest.spyOn(console, 'error').mockImplementation();
const { user } = await setup();
await attemptCreateTeam(user, MOCK_TEAMS[0].spec.title);
expect(screen.queryByText(/edit team page/i)).not.toBeInTheDocument();
});
});
+40 -37
View File
@@ -4,7 +4,7 @@ import { useForm } from 'react-hook-form';
import { NavModelItem } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getBackendSrv, locationService } from '@grafana/runtime';
import { Button, Field, Input, FieldSet } from '@grafana/ui';
import { Button, Field, Input, FieldSet, Stack } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
import { updateTeamRoles } from 'app/core/components/RolePicker/api';
@@ -35,17 +35,17 @@ export const CreateTeam = (): JSX.Element => {
contextSrv.hasPermission(AccessControlAction.ActionUserRolesRemove);
const createTeam = async (formModel: TeamDTO) => {
const newTeam = await getBackendSrv().post('/api/teams', formModel);
if (newTeam.teamId) {
try {
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);
}
} catch (e) {
console.error(e);
locationService.push(`/org/teams/edit/${newTeam.uid}`);
}
locationService.push(`/org/teams/edit/${newTeam.uid}`);
} catch (e) {
console.error(e);
}
};
@@ -54,39 +54,42 @@ export const CreateTeam = (): JSX.Element => {
<Page.Contents>
<form onSubmit={handleSubmit(createTeam)} style={{ maxWidth: '600px' }}>
<FieldSet>
<Field
label={t('teams.create-team.label-name', 'Name')}
required
invalid={!!errors.name}
error="Team name is required"
>
<Input {...register('name', { required: true })} id="team-name" />
</Field>
{contextSrv.licensedAccessControlEnabled() && (
<Field label={t('teams.create-team.label-role', 'Role')}>
<TeamRolePicker
teamId={0}
roleOptions={roleOptions}
disabled={false}
apply={true}
onApplyRoles={setPendingRoles}
pendingRoles={pendingRoles}
maxWidth="100%"
/>
<Stack direction="column" gap={2}>
<Field
noMargin
label={t('teams.create-team.label-name', 'Name')}
required
invalid={!!errors.name}
error="Team name is required"
>
<Input {...register('name', { required: true })} id="team-name" />
</Field>
)}
<Field
label={t('teams.create-team.label-email', 'Email')}
description={t(
'teams.create-team.description-email',
'This is optional and is primarily used for allowing custom team avatars'
{contextSrv.licensedAccessControlEnabled() && (
<Field noMargin label={t('teams.create-team.label-role', 'Role')}>
<TeamRolePicker
teamId={0}
roleOptions={roleOptions}
disabled={false}
apply={true}
onApplyRoles={setPendingRoles}
pendingRoles={pendingRoles}
maxWidth="100%"
/>
</Field>
)}
>
{/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */}
<Input {...register('email')} type="email" id="team-email" placeholder="email@test.com" />
</Field>
<Field
noMargin
label={t('teams.create-team.label-email', 'Email')}
description={t(
'teams.create-team.description-email',
'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" />
</Field>
</Stack>
</FieldSet>
<Button type="submit" variant="primary">
<Trans i18nKey="teams.create-team.create">Create</Trans>
</Button>