diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e98c47ddfac..2e7b5712911 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts index f614aed35a8..4f17bb46ecf 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts @@ -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; diff --git a/packages/grafana-test-utils/src/fixtures/teams.ts b/packages/grafana-test-utils/src/fixtures/teams.ts index 4039129fb5d..e12cee069dd 100644 --- a/packages/grafana-test-utils/src/fixtures/teams.ts +++ b/packages/grafana-test-utils/src/fixtures/teams.ts @@ -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 }>( - MOCK_TEAMS.map((team) => [team.metadata.name, { team, groups: [] }]) + MOCK_TEAMS.map((team) => [team.metadata.name, { team, groups: [...MOCK_TEAM_GROUPS] }]) ); diff --git a/packages/grafana-test-utils/src/handlers/api/teams/handlers.ts b/packages/grafana-test-utils/src/handlers/api/teams/handlers.ts index c8a42bf9bba..55cbf7a5621 100644 --- a/packages/grafana-test-utils/src/handlers/api/teams/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/teams/handlers.ts @@ -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(), diff --git a/packages/grafana-test-utils/src/unstable.ts b/packages/grafana-test-utils/src/unstable.ts index 8758e294464..d03bc685d9e 100644 --- a/packages/grafana-test-utils/src/unstable.ts +++ b/packages/grafana-test-utils/src/unstable.ts @@ -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'; diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index d2fdd854168..9f09c0135d7 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -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 diff --git a/public/api-merged.json b/public/api-merged.json index 570c4c2687d..323a545e0cf 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -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 diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index d86b93c916e..b73e12e2f22 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -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, diff --git a/public/app/features/teams/TeamGroupSync.test.tsx b/public/app/features/teams/TeamGroupSync.test.tsx index d3a83d9c5dc..8f0e98eda4c 100644 --- a/public/app/features/teams/TeamGroupSync.test.tsx +++ b/public/app/features/teams/TeamGroupSync.test.tsx @@ -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) => { - return render(, { - preloadedState: { - team: { - groups: [], - ...preloadedTeamState, - }, - }, - }); +const setup = () => { + return render(); }; 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() + ); }); }); diff --git a/public/app/features/teams/TeamGroupSync.tsx b/public/app/features/teams/TeamGroupSync.tsx index 37887ee8d6a..66a227527dc 100644 --- a/public/app/features/teams/TeamGroupSync.tsx +++ b/public/app/features/teams/TeamGroupSync.tsx @@ -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; - const headerTooltip = `Sync LDAP, OAuth or SAML groups with your Grafana teams.`; -export class TeamGroupSync extends PureComponent { - 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 = (event) => { - this.setState({ newGroupId: event.currentTarget.value }); + const onNewGroupIdChanged: FormEventHandler = (event) => { + setNewGroupId(event.currentTarget.value); }; - onAddGroup: FormEventHandler = (event) => { + const onAddGroup: FormEventHandler = 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 ( {group.groupId} @@ -87,7 +65,7 @@ export class TeamGroupSync extends PureComponent { - )} - - - -
- -
- - - - - - -
-
-
- - {groups.length === 0 && - !isAdding && - (highlightTrial() ? ( - - ) : ( - - ))} - +
{groups.length > 0 && ( -
- - - - - - - {groups.map((group) => this.renderGroup(group))} -
- External Group ID - -
-
+ )}
- ); - } -} + + +
+ +
+ + + + + + +
+
+
+ + {groups.length === 0 && + !isAddBoxVisible && + (highlightTrial() ? ( + + ) : ( + + ))} + + {groups.length > 0 && ( +
+ + + + + + + {groups.map((group) => renderGroup(group))} +
+ External Group ID + +
+
+ )} + + ); +}; 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({ diff --git a/public/app/features/teams/mocks/teamMocks.ts b/public/app/features/teams/mocks/teamMocks.ts index d6472499cb2..d43f1c454b2 100644 --- a/public/app/features/teams/mocks/teamMocks.ts +++ b/public/app/features/teams/mocks/teamMocks.ts @@ -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; -}; diff --git a/public/app/features/teams/state/actions.ts b/public/app/features/teams/state/actions.ts deleted file mode 100644 index d0d05e0946c..00000000000 --- a/public/app/features/teams/state/actions.ts +++ /dev/null @@ -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 { - return async (dispatch) => { - const response = await getBackendSrv().get(`/api/teams/${teamUid}/groups`); - dispatch(teamGroupsLoaded(response)); - }; -} - -export function addTeamGroup(teamUid: string, groupId: string): ThunkResult { - return async (dispatch) => { - await getBackendSrv().post(`/api/teams/${teamUid}/groups`, { groupId: groupId }); - dispatch(loadTeamGroups(teamUid)); - }; -} - -export function removeTeamGroup(teamUid: string, groupId: string): ThunkResult { - 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)); - }; -} diff --git a/public/app/features/teams/state/reducers.ts b/public/app/features/teams/state/reducers.ts deleted file mode 100644 index 50011316a72..00000000000 --- a/public/app/features/teams/state/reducers.ts +++ /dev/null @@ -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): TeamState => { - return { ...state, groups: action.payload }; - }, - }, -}); - -export const { teamGroupsLoaded } = teamSlice.actions; - -export const teamReducer = teamSlice.reducer; - -export default { - team: teamReducer, -}; diff --git a/public/app/features/teams/state/selectors.ts b/public/app/features/teams/state/selectors.ts deleted file mode 100644 index 4ada9ee8d46..00000000000 --- a/public/app/features/teams/state/selectors.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TeamState } from 'app/types/teams'; - -export const getTeamGroups = (state: TeamState) => state.groups; diff --git a/public/app/types/teams.ts b/public/app/types/teams.ts index 72202a21295..e78a74a3bda 100644 --- a/public/app/types/teams.ts +++ b/public/app/types/teams.ts @@ -37,7 +37,3 @@ export interface TeamGroup { groupId: string; teamId: number; } - -export interface TeamState { - groups: TeamGroup[]; -} diff --git a/public/openapi3.json b/public/openapi3.json index 3257a516bb0..9978e3dfa78 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -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" } } ],