[MM-69226] Fix Manage Teams role dropdown overflowing outside the modal (#37400)

* Fix Manage Teams dropdown overflowing outside modal for last rows

The role dropdown in the Admin Console Manage Teams modal always opened
downward, so for users with many teams the menu for the last rows was
pushed below the modal and out of view. Compute openUp for rows near the
bottom of the list, mirroring the TeamMembersDropdown behavior.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* Render Manage Teams role dropdown via floating menu to fix overflow

Migrate ManageTeamsDropdown from the deprecated Menu/MenuWrapper widget to
the modern components/menu (MUI Popover). The old widget positioned the
menu relative to the modal content, so for users with many teams the role
dropdown on the last rows was pushed below the modal and off-screen. The
new menu renders in a portal anchored to the trigger and opens upward for
rows near the bottom of the list, mirroring TeamMembersDropdown.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* Add openUp helper coverage and action tests for ManageTeamsDropdown

Extract the open-up positioning rule into a testable shouldOpenUp helper
and cover its boundaries, plus the demote and error paths, so the fix for
the dropdown overflow is exercised directly.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* Satisfy lint for table-driven test comment

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* Gate onMemberChange assertions behind waitFor in dropdown tests

Move onMemberChange expectations inside waitFor so they wait for the
async updateTeamMemberSchemeRoles promise to resolve before asserting.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* Fix Manage Teams e2e to target MUI menu button and items

The Manage Teams role dropdown was migrated from the deprecated
components/widgets/menu (div.MenuWrapper) to the modern components/menu
(MUI Popover). The Playwright spec still clicked div.MenuWrapper and
looked for menu items inside the team row, but the new menu renders in a
portal. Target the accessible role button and role menuitem elements
instead.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: mattermost-code <matty-code@mattermost.com>
This commit is contained in:
cursor[bot]
2026-08-13 09:24:45 +02:00
committed by GitHub
parent 0eb2ec5a17
commit 7831d7fbf1
5 changed files with 236 additions and 171 deletions
@@ -63,6 +63,7 @@ test('MM-T5520-2 should change user roles', async ({pw}) => {
test('MM-T5520-3 should be able to manage teams', async ({pw}) => {
const {systemConsolePage} = await setupAndGetRandomUser(pw);
const {page} = systemConsolePage;
const userRow = systemConsolePage.users.usersTable.getRowByIndex(0);
@@ -70,28 +71,27 @@ test('MM-T5520-3 should be able to manage teams', async ({pw}) => {
const actionMenu = await userRow.openActionMenu();
await actionMenu.clickManageTeams();
// # Click Make Team Admin
const team = systemConsolePage.page.locator('div.manage-teams__team');
const teamDropdown = team.locator('div.MenuWrapper');
await teamDropdown.click();
const makeTeamAdmin = teamDropdown.getByText('Make Team Admin');
await makeTeamAdmin.click();
// The role dropdown renders its menu in a portal (MUI Popover), so the menu
// items live at the page level rather than inside the team row.
const team = page.locator('div.manage-teams__team');
// # Open the role dropdown and click Make Team Admin
await team.getByRole('button', {name: 'Team Member'}).click();
await page.getByRole('menuitem', {name: 'Make Team Admin'}).click();
// * Verify role is updated
await expect(team.getByText('Team Admin')).toBeVisible();
// # Change back to Team Member
await teamDropdown.click();
const makeTeamMember = teamDropdown.getByText('Make Team Member');
await makeTeamMember.click();
await team.getByRole('button', {name: 'Team Admin'}).click();
await page.getByRole('menuitem', {name: 'Make Team Member'}).click();
// * Verify role is updated
await expect(team.getByText('Team Member')).toBeVisible();
// # Click Remove From Team
await teamDropdown.click();
const removeFromTeam = teamDropdown.getByText('Remove From Team');
await removeFromTeam.click();
// # Click Remove from Team
await team.getByRole('button', {name: 'Team Member'}).click();
await page.getByRole('menuitem', {name: 'Remove from Team'}).click();
// * The team should be detached
await team.waitFor({state: 'detached'});
@@ -1,73 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`ManageTeamsDropdown should match snapshot for guest 1`] = `
<div>
<div
class="MenuWrapper "
>
<a>
<span>
Guest
</span>
<span
class="caret"
/>
</a>
</div>
</div>
`;
exports[`ManageTeamsDropdown should match snapshot for system admin 1`] = `
<div>
<div
class="MenuWrapper "
>
<a>
<span>
System Admin
</span>
<span
class="caret"
/>
</a>
</div>
</div>
`;
exports[`ManageTeamsDropdown should match snapshot for team admin 1`] = `
<div>
<div
class="MenuWrapper "
>
<a>
<span>
Team Admin
</span>
<span
class="caret"
/>
</a>
</div>
</div>
`;
exports[`ManageTeamsDropdown should match snapshot for team member 1`] = `
<div>
<div
class="MenuWrapper "
>
<a>
<span>
Team Member
</span>
<span
class="caret"
/>
</a>
</div>
</div>
`;
@@ -3,14 +3,33 @@
import React from 'react';
import ManageTeamsDropdown from 'components/admin_console/manage_teams_modal/manage_teams_dropdown';
import ManageTeamsDropdown, {shouldOpenUp} from 'components/admin_console/manage_teams_modal/manage_teams_dropdown';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
describe('shouldOpenUp', () => {
test.each([
// Short lists never open up, so a short modal keeps its natural downward menus.
[0, 1, false],
[0, 3, false],
[2, 3, false],
// Long lists open up only for the last few rows so the menu stays inside the modal.
[0, 20, false],
[16, 20, false],
[17, 20, true],
[18, 20, true],
[19, 20, true],
])('index %i of %i teams -> openUp %s', (index, totalTeams, expected) => {
expect(shouldOpenUp(index, totalTeams)).toBe(expected);
});
});
describe('ManageTeamsDropdown', () => {
const baseProps = {
team: TestHelper.getTeamMock(),
team: TestHelper.getTeamMock({id: 'teamid', group_constrained: false}),
user: TestHelper.getUserMock({
id: 'currentUserId',
last_picture_update: 1234,
@@ -24,86 +43,152 @@ describe('ManageTeamsDropdown', () => {
scheme_guest: false,
scheme_admin: false,
}),
index: 0,
totalTeams: 1,
onError: jest.fn(),
onMemberChange: jest.fn(),
updateTeamMemberSchemeRoles: jest.fn(),
updateTeamMemberSchemeRoles: jest.fn().mockResolvedValue({}),
handleRemoveUserFromTeam: jest.fn(),
};
test('should match snapshot for team member', () => {
const {container} = renderWithContext(
<ManageTeamsDropdown {...baseProps}/>,
);
expect(screen.getByText('Team Member')).toBeInTheDocument();
expect(container).toMatchSnapshot();
beforeEach(() => {
jest.clearAllMocks();
});
test('should match snapshot for system admin', () => {
const user = {
...baseProps.user,
roles: 'system_admin',
};
test('shows the "Team Member" role for a plain member and the member actions when opened', async () => {
renderWithContext(<ManageTeamsDropdown {...baseProps}/>);
const props = {
...baseProps,
user,
};
await userEvent.click(screen.getByRole('button', {name: /Team Member/i}));
const {container} = renderWithContext(
<ManageTeamsDropdown {...props}/>,
);
expect(screen.getByText('System Admin')).toBeInTheDocument();
expect(container).toMatchSnapshot();
expect(screen.getByRole('menuitem', {name: 'Make Team Admin'})).toBeInTheDocument();
expect(screen.getByRole('menuitem', {name: 'Remove from Team'})).toBeInTheDocument();
expect(screen.queryByRole('menuitem', {name: 'Make Team Member'})).not.toBeInTheDocument();
});
test('should match snapshot for team admin', () => {
const user = {
...baseProps.user,
roles: 'system_user',
};
const teamMember = {
...baseProps.teamMember,
scheme_admin: true,
};
test('shows the "Team Admin" role and the demote action for a team admin', async () => {
const props = {
...baseProps,
user,
teamMember,
teamMember: {...baseProps.teamMember, scheme_admin: true},
};
const {container} = renderWithContext(
<ManageTeamsDropdown {...props}/>,
);
renderWithContext(<ManageTeamsDropdown {...props}/>);
expect(screen.getByText('Team Admin')).toBeInTheDocument();
expect(container).toMatchSnapshot();
await userEvent.click(screen.getByRole('button', {name: /Team Admin/i}));
expect(screen.getByRole('menuitem', {name: 'Make Team Member'})).toBeInTheDocument();
expect(screen.getByRole('menuitem', {name: 'Remove from Team'})).toBeInTheDocument();
expect(screen.queryByRole('menuitem', {name: 'Make Team Admin'})).not.toBeInTheDocument();
});
test('should match snapshot for guest', () => {
const user = {
...baseProps.user,
roles: 'system_guest',
};
const teamMember = {
...baseProps.teamMember,
};
test('shows the "Guest" role and no promotion action for a guest', async () => {
const props = {
...baseProps,
user,
teamMember,
user: {...baseProps.user, roles: 'system_guest'},
};
const {container} = renderWithContext(
<ManageTeamsDropdown {...props}/>,
);
renderWithContext(<ManageTeamsDropdown {...props}/>);
expect(screen.getByText('Guest')).toBeInTheDocument();
expect(container).toMatchSnapshot();
await userEvent.click(screen.getByRole('button', {name: /Guest/i}));
expect(screen.getByRole('menuitem', {name: 'Remove from Team'})).toBeInTheDocument();
expect(screen.queryByRole('menuitem', {name: 'Make Team Admin'})).not.toBeInTheDocument();
expect(screen.queryByRole('menuitem', {name: 'Make Team Member'})).not.toBeInTheDocument();
});
test('hides "Remove from Team" for a group constrained team', async () => {
const props = {
...baseProps,
team: TestHelper.getTeamMock({id: 'teamid', group_constrained: true}),
};
renderWithContext(<ManageTeamsDropdown {...props}/>);
await userEvent.click(screen.getByRole('button', {name: /Team Member/i}));
expect(screen.getByRole('menuitem', {name: 'Make Team Admin'})).toBeInTheDocument();
expect(screen.queryByRole('menuitem', {name: 'Remove from Team'})).not.toBeInTheDocument();
});
test('promotes the member to team admin and reports the change', async () => {
renderWithContext(<ManageTeamsDropdown {...baseProps}/>);
await userEvent.click(screen.getByRole('button', {name: /Team Member/i}));
await userEvent.click(screen.getByRole('menuitem', {name: 'Make Team Admin'}));
await waitFor(() => {
expect(baseProps.updateTeamMemberSchemeRoles).toHaveBeenCalledWith('teamid', 'currentUserId', true, true);
expect(baseProps.onMemberChange).toHaveBeenCalledWith('teamid');
});
});
test('demotes a team admin to member and reports the change', async () => {
const props = {
...baseProps,
teamMember: {...baseProps.teamMember, scheme_admin: true},
};
renderWithContext(<ManageTeamsDropdown {...props}/>);
await userEvent.click(screen.getByRole('button', {name: /Team Admin/i}));
await userEvent.click(screen.getByRole('menuitem', {name: 'Make Team Member'}));
await waitFor(() => {
expect(baseProps.updateTeamMemberSchemeRoles).toHaveBeenCalledWith('teamid', 'currentUserId', true, false);
expect(baseProps.onMemberChange).toHaveBeenCalledWith('teamid');
});
});
test('removes the user from the team', async () => {
renderWithContext(<ManageTeamsDropdown {...baseProps}/>);
await userEvent.click(screen.getByRole('button', {name: /Team Member/i}));
await userEvent.click(screen.getByRole('menuitem', {name: 'Remove from Team'}));
await waitFor(() => {
expect(baseProps.handleRemoveUserFromTeam).toHaveBeenCalledWith('teamid');
});
});
test('surfaces the promotion error message when the request fails', async () => {
const onError = jest.fn();
const props = {
...baseProps,
onError,
onMemberChange: jest.fn(),
updateTeamMemberSchemeRoles: jest.fn().mockResolvedValue({error: {message: 'boom'}}),
};
renderWithContext(<ManageTeamsDropdown {...props}/>);
await userEvent.click(screen.getByRole('button', {name: /Team Member/i}));
await userEvent.click(screen.getByRole('menuitem', {name: 'Make Team Admin'}));
await waitFor(() => {
expect(onError).toHaveBeenCalled();
});
expect(onError.mock.calls[0][0].props.id).toBe('admin.manage_teams.makeAdminError');
expect(props.onMemberChange).not.toHaveBeenCalled();
});
test('surfaces the demotion error message when the request fails', async () => {
const onError = jest.fn();
const props = {
...baseProps,
teamMember: {...baseProps.teamMember, scheme_admin: true},
onError,
onMemberChange: jest.fn(),
updateTeamMemberSchemeRoles: jest.fn().mockResolvedValue({error: {message: 'boom'}}),
};
renderWithContext(<ManageTeamsDropdown {...props}/>);
await userEvent.click(screen.getByRole('button', {name: /Team Admin/i}));
await userEvent.click(screen.getByRole('menuitem', {name: 'Make Team Member'}));
await waitFor(() => {
expect(onError).toHaveBeenCalled();
});
expect(onError.mock.calls[0][0].props.id).toBe('admin.manage_teams.makeMemberError');
expect(props.onMemberChange).not.toHaveBeenCalled();
});
});
@@ -10,13 +10,23 @@ import type {UserProfile} from '@mattermost/types/users';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {isAdmin, isSystemAdmin, isGuest} from 'mattermost-redux/utils/user_utils';
import Menu from 'components/widgets/menu/menu';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import * as Menu from 'components/menu';
import DropdownIcon from 'components/widgets/icons/fa_dropdown_icon';
const ROWS_FROM_BOTTOM_TO_OPEN_UP = 3;
// The role dropdown opens upward for rows near the bottom of the list so its menu
// stays inside the modal instead of overflowing below it (MM-69226).
export function shouldOpenUp(index: number, totalTeams: number): boolean {
return totalTeams > ROWS_FROM_BOTTOM_TO_OPEN_UP && totalTeams - index <= ROWS_FROM_BOTTOM_TO_OPEN_UP;
}
type Props = {
team: Team;
user: UserProfile;
teamMember: TeamMembership;
index: number;
totalTeams: number;
onError: (error: JSX.Element) => void;
onMemberChange: (teamId: string) => void;
updateTeamMemberSchemeRoles: (teamId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean) => Promise<ActionResult>;
@@ -59,7 +69,10 @@ const ManageTeamsDropdown = (props: Props) => {
const isSysAdmin = isSystemAdmin(props.user.roles);
const isGuestUser = isGuest(props.user.roles);
const {team} = props;
const {team, index, totalTeams} = props;
const openUp = shouldOpenUp(index, totalTeams);
let title;
if (isSysAdmin) {
title = formatMessage({id: 'admin.user_item.sysAdmin', defaultMessage: 'System Admin'});
@@ -71,33 +84,71 @@ const ManageTeamsDropdown = (props: Props) => {
title = formatMessage({id: 'admin.user_item.teamMember', defaultMessage: 'Team Member'});
}
const showMakeTeamAdmin = !isTeamAdmin && !isGuestUser;
const showRemoveFromTeam = !team.group_constrained;
return (
<MenuWrapper>
<a>
<span>{title} </span>
<span className='caret'/>
</a>
<Menu
openLeft={true}
ariaLabel={formatMessage({id: 'team_members_dropdown.menuAriaLabel', defaultMessage: 'Change the role of a team member'})}
>
<Menu.ItemAction
show={!isTeamAdmin && !isGuestUser}
<Menu.Container
menuButton={{
id: `manageTeamsDropdown_${team.id}`,
class: 'dropdown-toggle theme color--link style--none',
children: (
<>
<span>{title} </span>
<DropdownIcon/>
</>
),
}}
menu={{
id: `manageTeamsDropdown_${team.id}_menu`,
'aria-label': formatMessage({id: 'team_members_dropdown.menuAriaLabel', defaultMessage: 'Change the role of a team member'}),
}}
anchorOrigin={{
vertical: openUp ? 'top' : 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: openUp ? 'bottom' : 'top',
horizontal: 'right',
}}
>
{showMakeTeamAdmin ? (
<Menu.Item
id='makeTeamAdmin'
onClick={makeTeamAdmin}
text={formatMessage({id: 'admin.user_item.makeTeamAdmin', defaultMessage: 'Make Team Admin'})}
labels={
<FormattedMessage
id='admin.user_item.makeTeamAdmin'
defaultMessage='Make Team Admin'
/>
}
/>
<Menu.ItemAction
show={isTeamAdmin}
) : null}
{isTeamAdmin ? (
<Menu.Item
id='makeTeamMember'
onClick={makeMember}
text={formatMessage({id: 'admin.user_item.makeMember', defaultMessage: 'Make Team Member'})}
labels={
<FormattedMessage
id='admin.user_item.makeMember'
defaultMessage='Make Team Member'
/>
}
/>
<Menu.ItemAction
show={!team.group_constrained}
) : null}
{showRemoveFromTeam ? (
<Menu.Item
id='removeFromTeam'
onClick={removeFromTeam}
text={formatMessage({id: 'team_members_dropdown.leave_team', defaultMessage: 'Remove from Team'})}
labels={
<FormattedMessage
id='team_members_dropdown.leave_team'
defaultMessage='Remove from Team'
/>
}
/>
</Menu>
</MenuWrapper>
) : null}
</Menu.Container>
);
};
@@ -107,7 +107,7 @@ const ManageTeamsModal = ({locale, onExited, user, actions}: Props) => {
let teamList;
if (teams && teamMembers) {
teamList = teams.map((team) => {
teamList = teams.map((team, index) => {
const teamMember = teamMembers.find((member: TeamMembership) => member.team_id === team.id);
if (!teamMember) {
return null;
@@ -123,6 +123,8 @@ const ManageTeamsModal = ({locale, onExited, user, actions}: Props) => {
user={user}
team={team}
teamMember={teamMember}
index={index}
totalTeams={teams.length}
onError={handleError}
onMemberChange={handleMemberChange}
updateTeamMemberSchemeRoles={actions.updateTeamMemberSchemeRoles}