From 034799c22194ca624dc29c27b2b11e2a95b292de Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:18:48 +0530 Subject: [PATCH] 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 --- .../channel_members_profile_popover.spec.ts | 49 +++++++ .../channel_members_rhs/member_list.tsx | 60 +++----- .../member_list_item.test.tsx | 130 ++++++++++++++++++ .../channel_members_rhs/member_list_item.tsx | 66 +++++++++ .../profile_popover_custom_attributes.tsx | 2 +- 5 files changed, 264 insertions(+), 43 deletions(-) create mode 100644 e2e-tests/playwright/specs/functional/channels/sidebar_right/channel_members_profile_popover.spec.ts create mode 100644 webapp/channels/src/components/channel_members_rhs/member_list_item.test.tsx create mode 100644 webapp/channels/src/components/channel_members_rhs/member_list_item.tsx diff --git a/e2e-tests/playwright/specs/functional/channels/sidebar_right/channel_members_profile_popover.spec.ts b/e2e-tests/playwright/specs/functional/channels/sidebar_right/channel_members_profile_popover.spec.ts new file mode 100644 index 00000000000..3a65f10c6df --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/sidebar_right/channel_members_profile_popover.spec.ts @@ -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(); +}); diff --git a/webapp/channels/src/components/channel_members_rhs/member_list.tsx b/webapp/channels/src/components/channel_members_rhs/member_list.tsx index c4c15b83fd6..02f2b984895 100644 --- a/webapp/channels/src/components/channel_members_rhs/member_list.tsx +++ b/webapp/channels/src/components/channel_members_rhs/member_list.tsx @@ -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 ( -
- l.type === ListItemType.Member).length} - member={member} - editing={editing} - actions={{openDirectMessage, fetchRemoteClusterInfo}} - /> -
- ); - case ListItemType.Separator: - case ListItemType.FirstSeparator: - return ( -
- {members[index].data as JSX.Element} -
- ); - 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} )} diff --git a/webapp/channels/src/components/channel_members_rhs/member_list_item.test.tsx b/webapp/channels/src/components/channel_members_rhs/member_list_item.test.tsx new file mode 100644 index 00000000000..8d7c04abb47 --- /dev/null +++ b/webapp/channels/src/components/channel_members_rhs/member_list_item.test.tsx @@ -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) => ( +
+ {props.member.displayName} +
+ ); +}); + +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: {'Separator Label'}}, + ], + }; + + renderWithContext( + , + ); + + 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( + , + ); + + expect(screen.getByText('Test User')).toBeVisible(); + + rerender( + , + ); + + expect(screen.getByText('Second User')).toBeVisible(); + }); +}); diff --git a/webapp/channels/src/components/channel_members_rhs/member_list_item.tsx b/webapp/channels/src/components/channel_members_rhs/member_list_item.tsx new file mode 100644 index 00000000000..d3a6b40b543 --- /dev/null +++ b/webapp/channels/src/components/channel_members_rhs/member_list_item.tsx @@ -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) => { + 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 ( +
+ +
+ ); + } + case ListItemType.Separator: + case ListItemType.FirstSeparator: + return ( +
+ {members[index].data as JSX.Element} +
+ ); + default: + return null; + } + } + + return null; +}); + +export default MemberListItem; diff --git a/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx b/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx index 199c282781b..d46fac1e0e4 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx @@ -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) {