Teams: Refactor TeamGroupSync UI to functional component and hooks (#115223)

* Refactor TeamGroupSync to use func component and hooks

* Fix tests, lint

* Lint prune suppressions

* Address feedback

* Address feedback - tests
This commit is contained in:
Misi
2025-12-16 16:48:38 +01:00
committed by GitHub
parent 52205fbf4f
commit c09cb08dec
16 changed files with 198 additions and 289 deletions
-5
View File
@@ -3107,11 +3107,6 @@
"count": 2
}
},
"public/app/features/teams/TeamGroupSync.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/templating/fieldAccessorCache.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -3440,16 +3440,16 @@ export type RemoveTeamGroupApiQueryApiResponse =
/** status 200 An OKResponse is returned if the request was successful. */ SuccessResponseBody;
export type RemoveTeamGroupApiQueryApiArg = {
groupId?: string;
teamId: number;
teamId: string;
};
export type GetTeamGroupsApiApiResponse = /** status 200 (empty) */ TeamGroupDto[];
export type GetTeamGroupsApiApiArg = {
teamId: number;
teamId: string;
};
export type AddTeamGroupApiApiResponse =
/** status 200 An OKResponse is returned if the request was successful. */ SuccessResponseBody;
export type AddTeamGroupApiApiArg = {
teamId: number;
teamId: string;
teamGroupMapping: TeamGroupMapping;
};
export type SearchTeamGroupsApiResponse = /** status 200 (empty) */ SearchTeamGroupsQueryResult;
@@ -24,13 +24,15 @@ export const MOCK_TEAMS = [
},
];
export const MOCK_TEAM_GROUPS = [{ groupId: 'cn=users,ou=groups,dc=grafana,dc=org' }, { groupId: 'another-group' }];
export const setupMockTeams = () => {
mockTeamsMap.clear();
MOCK_TEAMS.forEach((team) => {
mockTeamsMap.set(team.metadata.name, { team, groups: [] });
mockTeamsMap.set(team.metadata.name, { team, groups: [...MOCK_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: [] }])
MOCK_TEAMS.map((team) => [team.metadata.name, { team, groups: [...MOCK_TEAM_GROUPS] }])
);
@@ -79,7 +79,7 @@ const teamsGroupsHandler = () =>
return HttpResponse.json(team.groups);
});
const teamsUpdateGroupsHandler = () =>
const teamsAddGroupHandler = () =>
http.post<{ uid: string }, { groupId: string }>('/api/teams/:uid/groups', async ({ params, request }) => {
const teamData = mockTeamsMap.get(params.uid);
const body = await request.json();
@@ -95,6 +95,29 @@ const teamsUpdateGroupsHandler = () =>
return HttpResponse.json({ message: 'Group added to Team' });
});
const teamsRemoveGroupHandler = () =>
http.delete<{ uid: string }>('/api/teams/:uid/groups', async ({ params, request }) => {
const teamData = mockTeamsMap.get(params.uid);
const url = new URL(request.url);
const groupId = url.searchParams.get('groupId');
if (!teamData) {
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
}
if (!groupId) {
return HttpResponse.json({ message: 'Missing groupId' }, { status: 400 });
}
const updatedTeam = {
...teamData,
groups: teamData.groups.filter((g) => g.groupId !== groupId),
};
mockTeamsMap.set(params.uid, updatedTeam);
return HttpResponse.json({ message: 'Group removed from Team' });
});
const searchTeamsHandler = () =>
http.get('/api/teams/search', async ({ request }) => {
const url = new URL(request.url);
@@ -150,7 +173,8 @@ const updateTeamHandler = () =>
const handlers = [
teamsPreferencesHandler(),
teamsGroupsHandler(),
teamsUpdateGroupsHandler(),
teamsAddGroupHandler(),
teamsRemoveGroupHandler(),
searchTeamsHandler(),
getTeamHandler(),
deleteTeamHandler(),
+1 -1
View File
@@ -1,4 +1,4 @@
import { wellFormedTree } from './fixtures/folders';
export const getFolderFixtures = wellFormedTree;
export { MOCK_TEAMS } from './fixtures/teams';
export { MOCK_TEAMS, MOCK_TEAM_GROUPS } from './fixtures/teams';
+3 -6
View File
@@ -2263,8 +2263,7 @@
"operationId": "getTeamGroupsApi",
"parameters": [
{
"type": "integer",
"format": "int64",
"type": "string",
"name": "teamId",
"in": "path",
"required": true
@@ -2308,8 +2307,7 @@
}
},
{
"type": "integer",
"format": "int64",
"type": "string",
"name": "teamId",
"in": "path",
"required": true
@@ -2350,8 +2348,7 @@
"in": "query"
},
{
"type": "integer",
"format": "int64",
"type": "string",
"name": "teamId",
"in": "path",
"required": true
+3 -6
View File
@@ -9927,8 +9927,7 @@
"operationId": "getTeamGroupsApi",
"parameters": [
{
"type": "integer",
"format": "int64",
"type": "string",
"name": "teamId",
"in": "path",
"required": true
@@ -9972,8 +9971,7 @@
}
},
{
"type": "integer",
"format": "int64",
"type": "string",
"name": "teamId",
"in": "path",
"required": true
@@ -10014,8 +10012,7 @@
"in": "query"
},
{
"type": "integer",
"format": "int64",
"type": "string",
"name": "teamId",
"in": "path",
"required": true
-2
View File
@@ -23,7 +23,6 @@ import { reducer as pluginsReducer } from 'app/features/plugins/admin/state/redu
import userReducers from 'app/features/profile/state/reducers';
import serviceAccountsReducer from 'app/features/serviceaccounts/state/reducers';
import supportBundlesReducer from 'app/features/support-bundles/state/reducers';
import teamsReducers from 'app/features/teams/state/reducers';
import usersReducers from 'app/features/users/state/reducers';
import templatingReducers from 'app/features/variables/state/keyedVariablesReducer';
@@ -33,7 +32,6 @@ import { cleanUpAction } from '../actions/cleanUp';
const rootReducers = {
...sharedReducers,
...alertingReducers,
...teamsReducers,
...dashboardReducers,
...exploreReducers,
...dataSourcesReducers,
@@ -1,26 +1,17 @@
import { render, screen } from 'test/test-utils';
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 { MOCK_TEAMS, MOCK_TEAM_GROUPS } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
import { TeamGroup, TeamState } from 'app/types/teams';
import TeamGroupSync from './TeamGroupSync';
import { getMockTeamGroups } from './mocks/teamMocks';
setBackendSrv(backendSrv);
setupMockServer();
const setup = (preloadedTeamState?: Partial<TeamState>) => {
return render(<TeamGroupSync teamUid={MOCK_TEAMS[0].metadata.name} isReadOnly={false} />, {
preloadedState: {
team: {
groups: [],
...preloadedTeamState,
},
},
});
const setup = () => {
return render(<TeamGroupSync teamUid={MOCK_TEAMS[0].metadata.name} isReadOnly={false} />);
};
describe('TeamGroupSync', () => {
@@ -29,28 +20,37 @@ describe('TeamGroupSync', () => {
expect(screen.getByRole('heading', { name: /External group sync/i })).toBeInTheDocument();
});
it('should render groups table', () => {
setup({ groups: getMockTeamGroups(3) });
expect(screen.getAllByRole('row')).toHaveLength(4); // 3 items plus table header
it('should render groups table', async () => {
setup();
expect(await screen.findAllByRole('row')).toHaveLength(MOCK_TEAM_GROUPS.length + 1); // items plus table header
});
it('should call add group', async () => {
const { user } = setup();
// Empty List CTA "Add group" button is second in the DOM order
await user.click(screen.getAllByRole('button', { name: /add group/i })[1]);
// Wait for the groups to load so the "Add group" button appears
await screen.findAllByRole('row');
await user.click(screen.getAllByRole('button', { name: /add group/i })[0]);
expect(screen.getByRole('textbox', { name: /add external group/i })).toBeVisible();
await user.type(screen.getByRole('textbox', { name: /add external group/i }), 'test/group');
await user.click(screen.getAllByRole('button', { name: /add group/i })[0]);
await user.click(screen.getAllByRole('button', { name: /add group/i })[1]);
expect(screen.getByRole('row', { name: /test\/group/i })).toBeInTheDocument();
expect(await screen.findByRole('row', { name: /test\/group/i })).toBeInTheDocument();
});
it('should remove group', async () => {
const mockGroup: TeamGroup = { teamId: 1, groupId: 'someGroup' };
const { user } = setup({ groups: [mockGroup] });
await user.click(screen.getByRole('button', { name: 'Remove group someGroup' }));
const { user } = setup();
const groupToRemove = MOCK_TEAM_GROUPS[0].groupId;
expect(screen.queryByRole('row', { name: /test\/group/i })).not.toBeInTheDocument();
// Wait for group to be rendered
await screen.findByRole('row', { name: new RegExp(groupToRemove, 'i') });
// Remove group
await user.click(screen.getByRole('button', { name: `Remove group ${groupToRemove}` }));
await waitFor(() =>
expect(screen.queryByRole('row', { name: new RegExp(groupToRemove, 'i') })).not.toBeInTheDocument()
);
});
});
+130 -160
View File
@@ -1,85 +1,63 @@
import { css, cx } from '@emotion/css';
import { FormEventHandler, PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { FormEventHandler, useState } from 'react';
import {
TeamGroupDto,
useAddTeamGroupApiMutation,
useGetTeamGroupsApiQuery,
useRemoveTeamGroupApiQueryMutation,
} from '@grafana/api-clients/rtkq/legacy';
import { Trans, t } from '@grafana/i18n';
import { Input, Tooltip, Icon, Button, useTheme2, InlineField, InlineFieldRow } from '@grafana/ui';
import { Input, Tooltip, Icon, Button, useTheme2, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui';
import { SlideDown } from 'app/core/components/Animations/SlideDown';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { UpgradeBox, UpgradeContent, UpgradeContentProps } from 'app/core/components/Upgrade/UpgradeBox';
import { highlightTrial } from 'app/features/admin/utils';
import { StoreState } from 'app/types/store';
import { TeamGroup } from 'app/types/teams';
import { addTeamGroup, loadTeamGroups, removeTeamGroup } from './state/actions';
import { getTeamGroups } from './state/selectors';
function mapStateToProps(state: StoreState) {
return {
groups: getTeamGroups(state.team),
};
}
const mapDispatchToProps = {
loadTeamGroups,
addTeamGroup,
removeTeamGroup,
};
interface OwnProps {
interface Props {
isReadOnly: boolean;
teamUid: string;
}
interface State {
isAdding: boolean;
newGroupId: string;
}
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = OwnProps & ConnectedProps<typeof connector>;
const headerTooltip = `Sync LDAP, OAuth or SAML groups with your Grafana teams.`;
export class TeamGroupSync extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = { isAdding: false, newGroupId: '' };
}
export const TeamGroupSync = ({ isReadOnly, teamUid }: Props) => {
const [isAddBoxVisible, setIsAddBoxVisible] = useState(false);
const [newGroupId, setNewGroupId] = useState('');
const styles = useStyles2(getStyles);
componentDidMount() {
this.fetchTeamGroups();
}
const { data: groups = [] } = useGetTeamGroupsApiQuery({ teamId: teamUid });
const [addTeamGroup] = useAddTeamGroupApiMutation();
const [removeTeamGroup] = useRemoveTeamGroupApiQueryMutation();
async fetchTeamGroups() {
this.props.loadTeamGroups(this.props.teamUid);
}
onToggleAdding = () => {
this.setState({ isAdding: !this.state.isAdding });
const onToggleAdding = () => {
setIsAddBoxVisible(!isAddBoxVisible);
};
onNewGroupIdChanged: FormEventHandler<HTMLInputElement> = (event) => {
this.setState({ newGroupId: event.currentTarget.value });
const onNewGroupIdChanged: FormEventHandler<HTMLInputElement> = (event) => {
setNewGroupId(event.currentTarget.value);
};
onAddGroup: FormEventHandler<HTMLFormElement> = (event) => {
const onAddGroup: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
this.props.addTeamGroup(this.props.teamUid, this.state.newGroupId);
this.setState({ isAdding: false, newGroupId: '' });
await addTeamGroup({ teamId: teamUid, teamGroupMapping: { groupId: newGroupId } });
setIsAddBoxVisible(false);
setNewGroupId('');
};
onRemoveGroup = (group: TeamGroup) => {
this.props.removeTeamGroup(this.props.teamUid, group.groupId);
const onRemoveGroup = async (groupId: string | undefined) => {
if (!groupId) {
return;
}
await removeTeamGroup({ teamId: teamUid, groupId });
};
isNewGroupValid() {
return this.state.newGroupId.length > 1;
}
const isNewGroupValid = () => {
return newGroupId.length > 1;
};
renderGroup(group: TeamGroup) {
const { isReadOnly } = this.props;
const renderGroup = (group: TeamGroupDto) => {
return (
<tr key={group.groupId}>
<td>{group.groupId}</td>
@@ -87,7 +65,7 @@ export class TeamGroupSync extends PureComponent<Props, State> {
<Button
size="sm"
variant="destructive"
onClick={() => this.onRemoveGroup(group)}
onClick={() => onRemoveGroup(group.groupId)}
disabled={isReadOnly}
aria-label={t('teams.team-group-sync.aria-label-remove', 'Remove group {{groupName}}', {
groupName: group.groupId,
@@ -98,114 +76,106 @@ export class TeamGroupSync extends PureComponent<Props, State> {
</td>
</tr>
);
}
};
render() {
const { isAdding, newGroupId } = this.state;
const { groups, isReadOnly } = this.props;
const styles = getStyles();
return (
<div>
{highlightTrial() && (
<UpgradeBox
featureId={'team-sync'}
eventVariant={'trial'}
featureName={'team sync'}
text={t(
'teams.team-group-sync.team-sync-upgrade',
'Add a group to enable team sync for free during your trial of Grafana Pro'
)}
/>
return (
<div>
{highlightTrial() && (
<UpgradeBox
featureId={'team-sync'}
eventVariant={'trial'}
featureName={'team sync'}
text={t(
'teams.team-group-sync.team-sync-upgrade',
'Add a group to enable team sync for free during your trial of Grafana Pro'
)}
/>
)}
<div className="page-action-bar">
{(!highlightTrial() || groups.length > 0) && (
<>
<h3 className="page-sub-heading">
<Trans i18nKey="teams.team-group-sync.external-group-sync">External group sync</Trans>
</h3>
<Tooltip placement="auto" content={headerTooltip}>
<Icon className={cx(styles.icon, 'page-sub-heading-icon')} name="question-circle" />
</Tooltip>
</>
)}
<div className="page-action-bar">
{(!highlightTrial() || groups.length > 0) && (
<>
<h3 className="page-sub-heading">
<Trans i18nKey="teams.team-group-sync.external-group-sync">External group sync</Trans>
</h3>
<Tooltip placement="auto" content={headerTooltip}>
<Icon className={cx(styles.icon, 'page-sub-heading-icon')} name="question-circle" />
</Tooltip>
</>
)}
<div className="page-action-bar__spacer" />
{groups.length > 0 && (
<Button onClick={this.onToggleAdding} icon="plus" disabled={isReadOnly}>
<Trans i18nKey="teams.team-group-sync.add-group-button">Add group</Trans>
</Button>
)}
</div>
<SlideDown in={isAdding}>
<div className="cta-form">
<CloseButton onClick={this.onToggleAdding} />
<form onSubmit={this.onAddGroup}>
<InlineFieldRow>
<InlineField
label={t('teams.team-group-sync.label-add-external-group', 'Add external group')}
tooltip={t('teams.team-group-sync.tooltip-add-external-group', 'LDAP group example: {{example}}', {
example: 'cn=users,ou=groups,dc=grafana,dc=org',
})}
>
<Input
type="text"
id={'add-external-group'}
placeholder=""
value={newGroupId}
onChange={this.onNewGroupIdChanged}
disabled={isReadOnly}
/>
</InlineField>
<Button type="submit" disabled={isReadOnly || !this.isNewGroupValid()} style={{ marginLeft: 4 }}>
<Trans i18nKey="teams.team-group-sync.add-group">Add group</Trans>
</Button>
</InlineFieldRow>
</form>
</div>
</SlideDown>
{groups.length === 0 &&
!isAdding &&
(highlightTrial() ? (
<TeamSyncUpgradeContent
action={{ onClick: this.onToggleAdding, text: t('teams.team-group-sync.text.add-group', 'Add group') }}
/>
) : (
<EmptyListCTA
onClick={this.onToggleAdding}
buttonIcon="users-alt"
title={t(
'teams.team-group-sync.title-there-external-groups',
'There are no external groups to sync with'
)}
buttonTitle="Add group"
proTip={headerTooltip}
proTipLinkTitle="Learn more"
proTipLink="https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-team-sync/"
proTipTarget="_blank"
buttonDisabled={isReadOnly}
/>
))}
<div className="page-action-bar__spacer" />
{groups.length > 0 && (
<div className="admin-list-table">
<table className="filter-table filter-table--hover form-inline">
<thead>
<tr>
<th>
<Trans i18nKey="teams.team-group-sync.external-group-id">External Group ID</Trans>
</th>
<th style={{ width: '1%' }} />
</tr>
</thead>
<tbody>{groups.map((group) => this.renderGroup(group))}</tbody>
</table>
</div>
<Button onClick={onToggleAdding} icon="plus" disabled={isReadOnly}>
<Trans i18nKey="teams.team-group-sync.add-group-button">Add group</Trans>
</Button>
)}
</div>
);
}
}
<SlideDown in={isAddBoxVisible}>
<div className="cta-form">
<CloseButton onClick={onToggleAdding} />
<form onSubmit={onAddGroup}>
<InlineFieldRow>
<InlineField
label={t('teams.team-group-sync.label-add-external-group', 'Add external group')}
tooltip={t('teams.team-group-sync.tooltip-add-external-group', 'LDAP group example: {{example}}', {
example: 'cn=users,ou=groups,dc=grafana,dc=org',
})}
>
<Input
type="text"
id={'add-external-group'}
placeholder=""
value={newGroupId}
onChange={onNewGroupIdChanged}
disabled={isReadOnly}
/>
</InlineField>
<Button type="submit" disabled={isReadOnly || !isNewGroupValid()} style={{ marginLeft: 4 }}>
<Trans i18nKey="teams.team-group-sync.add-group">Add group</Trans>
</Button>
</InlineFieldRow>
</form>
</div>
</SlideDown>
{groups.length === 0 &&
!isAddBoxVisible &&
(highlightTrial() ? (
<TeamSyncUpgradeContent
action={{ onClick: onToggleAdding, text: t('teams.team-group-sync.text.add-group', 'Add group') }}
/>
) : (
<EmptyListCTA
onClick={onToggleAdding}
buttonIcon="users-alt"
title={t('teams.team-group-sync.title-there-external-groups', 'There are no external groups to sync with')}
buttonTitle="Add group"
proTip={headerTooltip}
proTipLinkTitle="Learn more"
proTipLink="https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-team-sync/"
proTipTarget="_blank"
buttonDisabled={isReadOnly}
/>
))}
{groups.length > 0 && (
<div className="admin-list-table">
<table className="filter-table filter-table--hover form-inline">
<thead>
<tr>
<th>
<Trans i18nKey="teams.team-group-sync.external-group-id">External Group ID</Trans>
</th>
<th style={{ width: '1%' }} />
</tr>
</thead>
<tbody>{groups.map((group) => renderGroup(group))}</tbody>
</table>
</div>
)}
</div>
);
};
export const TeamSyncUpgradeContent = ({ action }: { action?: UpgradeContentProps['action'] }) => {
const theme = useTheme2();
@@ -226,7 +196,7 @@ export const TeamSyncUpgradeContent = ({ action }: { action?: UpgradeContentProp
/>
);
};
export default connect(mapStateToProps, mapDispatchToProps)(TeamGroupSync);
export default TeamGroupSync;
const getStyles = () => ({
icon: css({
+1 -14
View File
@@ -1,7 +1,7 @@
import { randomBytes } from 'crypto';
import { TeamPermissionLevel } from 'app/types/acl';
import { Team, TeamMember, TeamGroup } from 'app/types/teams';
import { Team, TeamMember } from 'app/types/teams';
function generateShortUid(): string {
return randomBytes(3).toString('hex'); // Generate a short UID
@@ -44,16 +44,3 @@ export const getMockTeamMember = (): TeamMember => {
permission: TeamPermissionLevel.Member,
};
};
export const getMockTeamGroups = (amount: number): TeamGroup[] => {
const groups: TeamGroup[] = [];
for (let i = 1; i <= amount; i++) {
groups.push({
groupId: `group-${i}`,
teamId: 1,
});
}
return groups;
};
@@ -1,26 +0,0 @@
import { getBackendSrv } from '@grafana/runtime';
import { ThunkResult } from 'app/types/store';
import { teamGroupsLoaded } from './reducers';
export function loadTeamGroups(teamUid: string): ThunkResult<void> {
return async (dispatch) => {
const response = await getBackendSrv().get(`/api/teams/${teamUid}/groups`);
dispatch(teamGroupsLoaded(response));
};
}
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(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/${teamUid}/groups?groupId=${encodeURIComponent(groupId)}`);
dispatch(loadTeamGroups(teamUid));
};
}
@@ -1,25 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { TeamState, TeamGroup } from 'app/types/teams';
export const initialTeamState: TeamState = {
groups: [],
};
const teamSlice = createSlice({
name: 'team',
initialState: initialTeamState,
reducers: {
teamGroupsLoaded: (state, action: PayloadAction<TeamGroup[]>): TeamState => {
return { ...state, groups: action.payload };
},
},
});
export const { teamGroupsLoaded } = teamSlice.actions;
export const teamReducer = teamSlice.reducer;
export default {
team: teamReducer,
};
@@ -1,3 +0,0 @@
import { TeamState } from 'app/types/teams';
export const getTeamGroups = (state: TeamState) => state.groups;
-4
View File
@@ -37,7 +37,3 @@ export interface TeamGroup {
groupId: string;
teamId: number;
}
export interface TeamState {
groups: TeamGroup[];
}
+3 -6
View File
@@ -24691,8 +24691,7 @@
"name": "teamId",
"required": true,
"schema": {
"format": "int64",
"type": "integer"
"type": "string"
}
}
],
@@ -24730,8 +24729,7 @@
"name": "teamId",
"required": true,
"schema": {
"format": "int64",
"type": "integer"
"type": "string"
}
}
],
@@ -24769,8 +24767,7 @@
"name": "teamId",
"required": true,
"schema": {
"format": "int64",
"type": "integer"
"type": "string"
}
}
],