Fixed a bug where user profile popover closed automatically when opened for the first time for a user from channel member list in RHS (#35918)

* Fixed a bug where user profile popover closed automatically when opened for the first time for a user from channel member list in RHS

* Added tests

* fixed a test
This commit is contained in:
Harshil Sharma
2026-04-16 12:18:48 +05:30
committed by GitHub
parent ab0a579b47
commit 034799c221
5 changed files with 264 additions and 43 deletions
@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test('should open and close profile popover from channel members RHS', async ({pw}) => {
// # Initialize setup with two users in the same team and channel
const {user, team, adminClient} = await pw.initSetup();
const channel = await adminClient.createChannel(
pw.random.channel({teamId: team.id, displayName: 'Test Channel', name: 'test-channel'}),
);
await adminClient.addToChannel(user.id, channel.id);
const testUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.addToTeam(team.id, testUser.id);
await adminClient.addToChannel(testUser.id, channel.id);
// # Log in and navigate to the channel
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Open channel members RHS by clicking the Members button in the header
await channelsPage.centerView.header.openChannelMenu();
const membersMenuItem = page.locator('#channelMembers');
await membersMenuItem.click();
// * Verify the channel members RHS is visible
await channelsPage.sidebarRight.toBeVisible();
// # Find the test user in the member list and click their display name to open profile popover
const memberEntry = page.getByTestId(`memberline-${testUser.id}`);
await expect(memberEntry).toBeVisible();
const displayName = memberEntry.locator('.channel-members-rhs__display-name');
await displayName.click();
// * Verify the profile popover is visible
const popover = channelsPage.userProfilePopover;
await popover.toBeVisible();
await expect(popover.container.getByText(`@${testUser.username}`)).toBeVisible();
// # Click outside the popover to close it
await page.mouse.click(1, 1);
// * Verify the profile popover is no longer visible
await expect(popover.container).not.toBeVisible();
});
@@ -1,16 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useEffect, useRef, useState} from 'react';
import React, {memo, useEffect, useMemo, useRef, useState} from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import {VariableSizeList} from 'react-window';
import type {ListChildComponentProps} from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import Member from './member';
import MemberListItem from './member_list_item';
import type {ItemData} from './member_list_item';
export interface ChannelMember {
user: UserProfile;
@@ -92,44 +92,20 @@ const MemberList = ({
return 48;
};
const Item = ({index, style}: ListChildComponentProps) => {
if (isItemLoaded(index)) {
switch (members[index].type) {
case ListItemType.Member:
// eslint-disable-next-line no-case-declarations
const member = members[index].data as ChannelMember;
return (
<div
style={style}
key={member.user.id}
>
<Member
channel={channel}
index={index}
totalUsers={members.filter((l) => l.type === ListItemType.Member).length}
member={member}
editing={editing}
actions={{openDirectMessage, fetchRemoteClusterInfo}}
/>
</div>
);
case ListItemType.Separator:
case ListItemType.FirstSeparator:
return (
<div
key={index}
style={style}
>
{members[index].data as JSX.Element}
</div>
);
default:
return null;
}
}
const totalMemberCount = useMemo(
() => members.filter((l) => l.type === ListItemType.Member).length,
[members],
);
return null;
};
const itemData: ItemData = useMemo(() => ({
members,
hasNextPage,
channel,
editing,
totalMemberCount,
openDirectMessage,
fetchRemoteClusterInfo,
}), [members, hasNextPage, channel, editing, totalMemberCount, openDirectMessage, fetchRemoteClusterInfo]);
if (members.length === 0) {
return null;
@@ -153,12 +129,12 @@ const MemberList = ({
ref(list);
variableSizeListRef.current = list;
}}
itemData={itemData}
itemSize={getItemSize}
height={height}
width={width}
>
{Item}
{MemberListItem}
</VariableSizeList>
)}
</InfiniteLoader>
@@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ChannelType} from '@mattermost/types/channels';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {ChannelMember} from './member_list';
import {ListItemType} from './member_list';
import type {ItemData} from './member_list_item';
import MemberListItem from './member_list_item';
jest.mock('./member', () => {
return (props: any) => (
<div data-testid={`mock-member-${props.member.user.id}`}>
{props.member.displayName}
</div>
);
});
describe('components/channel_members_rhs/MemberListItem', () => {
const mockChannel = TestHelper.getChannelMock({
id: 'channel_id',
display_name: 'Test Channel',
name: 'test-channel',
type: 'O' as ChannelType,
team_id: 'team_id',
});
const mockUser = TestHelper.getUserMock({
id: 'user_id_1',
username: 'testuser',
nickname: 'Test User',
roles: 'system_user',
});
const mockMembership = TestHelper.getChannelMembershipMock({
channel_id: 'channel_id',
user_id: 'user_id_1',
});
const mockMember: ChannelMember = {
user: mockUser,
membership: mockMembership,
status: 'online',
displayName: 'Test User',
};
const baseItemData: ItemData = {
members: [
{type: ListItemType.Member, data: mockMember},
],
hasNextPage: false,
channel: mockChannel,
editing: false,
totalMemberCount: 1,
openDirectMessage: jest.fn(),
fetchRemoteClusterInfo: jest.fn(),
};
const baseStyle = {top: 0, left: 0, width: '100%', height: 48, position: 'absolute' as const};
test('should render a Member component for a member item', () => {
const separatorData: ItemData = {
...baseItemData,
members: [
{type: ListItemType.Separator, data: <span>{'Separator Label'}</span>},
],
};
renderWithContext(
<MemberListItem
index={0}
style={baseStyle}
data={separatorData}
isScrolling={false}
/>,
);
expect(screen.getByText('Separator Label')).toBeVisible();
});
test('should render multiple members at different indices', () => {
const secondUser = TestHelper.getUserMock({
id: 'user_id_2',
username: 'seconduser',
nickname: 'Second User',
});
const secondMember: ChannelMember = {
...mockMember,
user: secondUser,
displayName: 'Second User',
};
const multiMemberData: ItemData = {
...baseItemData,
members: [
{type: ListItemType.Member, data: mockMember},
{type: ListItemType.Member, data: secondMember},
],
totalMemberCount: 2,
};
const {rerender} = renderWithContext(
<MemberListItem
index={0}
style={baseStyle}
data={multiMemberData}
isScrolling={false}
/>,
);
expect(screen.getByText('Test User')).toBeVisible();
rerender(
<MemberListItem
index={1}
style={baseStyle}
data={multiMemberData}
isScrolling={false}
/>,
);
expect(screen.getByText('Second User')).toBeVisible();
});
});
@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import type {ListChildComponentProps} from 'react-window';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import Member from './member';
import type {ChannelMember, ListItem} from './member_list';
import {ListItemType} from './member_list';
export interface ItemData {
members: ListItem[];
hasNextPage: boolean;
channel: Channel;
editing: boolean;
totalMemberCount: number;
openDirectMessage: (user: UserProfile) => void;
fetchRemoteClusterInfo: (remoteId: string, includeDeleted?: boolean, forceRefresh?: boolean) => void;
}
const MemberListItem = memo(({index, style, data}: ListChildComponentProps<ItemData>) => {
const {members, hasNextPage, channel, editing, totalMemberCount, openDirectMessage, fetchRemoteClusterInfo} = data;
const isItemLoaded = !hasNextPage || index < members.length;
if (isItemLoaded) {
switch (members[index].type) {
case ListItemType.Member: {
const member = members[index].data as ChannelMember;
return (
<div
style={style}
key={member.user.id}
>
<Member
channel={channel}
index={index}
totalUsers={totalMemberCount}
member={member}
editing={editing}
actions={{openDirectMessage, fetchRemoteClusterInfo}}
/>
</div>
);
}
case ListItemType.Separator:
case ListItemType.FirstSeparator:
return (
<div
key={index}
style={style}
>
{members[index].data as JSX.Element}
</div>
);
default:
return null;
}
}
return null;
});
export default MemberListItem;
@@ -33,7 +33,7 @@ const ProfilePopoverCustomAttributes = ({
if (!userProfile.custom_profile_attributes) {
dispatch(getCustomProfileAttributeValues(userID));
}
});
}, [dispatch, userID, userProfile.custom_profile_attributes]);
const attributeSections = customProfileAttributeFields.map((attribute) => {
if (!hideStatus && userProfile.custom_profile_attributes) {