[MM-66375] Allow managing own membership on a channel (#9301)

* [MM-66375] Allow managing own membership on a channel

* Add e2e test
This commit is contained in:
Daniel Espino García 2025-12-03 17:20:20 +01:00 committed by GitHub
parent 3990de8932
commit 51393f13f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 235 additions and 102 deletions

View file

@ -12,7 +12,6 @@ import type {AvailableScreens} from '@typings/screens/navigation';
import type {SectionListData} from 'react-native';
type Props = {
currentUserId: string;
tutorialWatched: boolean;
handleSelectProfile: (user: UserProfile) => void;
term: string;
@ -26,7 +25,6 @@ type Props = {
}
export default function ServerUserList({
currentUserId,
tutorialWatched,
handleSelectProfile,
term,
@ -123,7 +121,6 @@ export default function ServerUserList({
return (
<UserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
loading={loading}
profiles={data}

View file

@ -113,7 +113,6 @@ describe('components/channel_list_row', () => {
return {
profiles: [],
testID: 'UserListRow',
currentUserId: '1',
handleSelectProfile: jest.fn(),
fetchMore: jest.fn(),
loading: true,

View file

@ -172,7 +172,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
type Props = {
profiles: UserProfile[];
channelMembers?: ChannelMembership[];
currentUserId: string;
handleSelectProfile: (user: UserProfile | UserModel) => void;
fetchMore?: () => void;
loading: boolean;
@ -192,7 +191,6 @@ export default function UserList({
profiles,
channelMembers,
selectedIds,
currentUserId,
handleSelectProfile,
fetchMore,
loading,
@ -262,7 +260,6 @@ export default function UserList({
highlight={section?.first && index === 0}
id={item.id}
isChannelAdmin={isChAdmin}
isMyUser={currentUserId === item.id}
manageMode={manageMode}
onPress={handleSelectProfile}
onLongPress={openUserProfile}
@ -276,7 +273,7 @@ export default function UserList({
includeMargin={includeUserMargin}
/>
);
}, [selectedIds, currentUserId, manageMode, handleSelectProfile, openUserProfile, showManageMode, tutorialWatched, includeUserMargin]);
}, [selectedIds, manageMode, handleSelectProfile, openUserProfile, showManageMode, tutorialWatched, includeUserMargin]);
const renderLoading = useCallback(() => {
if (!loading) {
@ -290,7 +287,7 @@ export default function UserList({
size='large'
/>
);
}, [loading, theme]);
}, [loading, style.loadingContainer, theme.buttonBg]);
const renderNoResults = useCallback(() => {
if (!showNoResults || !term) {
@ -302,7 +299,7 @@ export default function UserList({
<NoResultsWithTerm term={term}/>
</View>
);
}, [showNoResults && style, term, noResutsStyle]);
}, [showNoResults, term, noResutsStyle]);
const renderSectionHeader = useCallback(({section}: {section: SectionListData<UserProfile>}) => {
return (

View file

@ -26,7 +26,6 @@ type Props = {
highlight?: boolean;
id: string;
includeMargin?: boolean;
isMyUser: boolean;
isChannelAdmin: boolean;
manageMode: boolean;
onLongPress: (user: UserProfile | UserModel) => void;
@ -82,7 +81,6 @@ const messages = defineMessages({
function UserListRow({
id,
includeMargin,
isMyUser,
highlight,
isChannelAdmin,
onPress,
@ -129,7 +127,7 @@ function UserListRow({
}, [onPress]);
const manageModeIcon = useMemo(() => {
if (!showManageMode || isMyUser) {
if (!showManageMode) {
return null;
}
@ -149,7 +147,7 @@ function UserListRow({
/>
</View>
);
}, [isChannelAdmin, isMyUser, showManageMode, style.manageText, style.selectorManage, theme.centerChannelColor]);
}, [isChannelAdmin, showManageMode, style.manageText, style.selectorManage, theme.centerChannelColor]);
const onLayout = useCallback(() => {
if (highlight && !tutorialWatched) {

View file

@ -54,24 +54,6 @@ describe('SelectUser', () => {
const selectUser = getByTestId('select-user');
// Default values from observables when no data exists
expect(selectUser.props.currentUserId).toBe('');
expect(selectUser.props.currentTeamId).toBe('');
});
it('should render correctly with current user data', async () => {
await operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
value: 'current-user-id',
}],
prepareRecordsOnly: false,
});
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<SelectUser {...props}/>, {database});
const selectUser = getByTestId('select-user');
expect(selectUser.props.currentUserId).toBe('current-user-id');
expect(selectUser.props.currentTeamId).toBe('');
});
@ -89,39 +71,11 @@ describe('SelectUser', () => {
const selectUser = getByTestId('select-user');
expect(selectUser.props.currentTeamId).toBe('current-team-id');
expect(selectUser.props.currentUserId).toBe('');
});
it('should render correctly with both current user and team data', async () => {
await operator.handleSystem({
systems: [
{
id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
value: 'current-user-id',
},
{
id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
value: 'current-team-id',
},
],
prepareRecordsOnly: false,
});
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<SelectUser {...props}/>, {database});
const selectUser = getByTestId('select-user');
expect(selectUser.props.currentUserId).toBe('current-user-id');
expect(selectUser.props.currentTeamId).toBe('current-team-id');
});
it('should update observables when data changes', async () => {
await operator.handleSystem({
systems: [
{
id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
value: 'current-user-id',
},
{
id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
value: 'current-team-id',
@ -134,25 +88,8 @@ describe('SelectUser', () => {
const {getByTestId} = renderWithEverything(<SelectUser {...props}/>, {database});
const selectUser = getByTestId('select-user');
expect(selectUser.props.currentUserId).toBe('current-user-id');
expect(selectUser.props.currentTeamId).toBe('current-team-id');
await act(async () => {
// Update current user ID
await operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
value: 'new-user-id',
}],
prepareRecordsOnly: false,
});
});
await waitFor(() => {
expect(selectUser.props.currentUserId).toBe('new-user-id');
expect(selectUser.props.currentTeamId).toBe('current-team-id');
});
await act(async () => {
// Update current team ID
await operator.handleSystem({
@ -165,7 +102,6 @@ describe('SelectUser', () => {
});
await waitFor(() => {
expect(selectUser.props.currentUserId).toBe('new-user-id');
expect(selectUser.props.currentTeamId).toBe('new-team-id');
});
});

View file

@ -2,14 +2,13 @@
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeCurrentTeamId} from '@queries/servers/system';
import SelectUser from './select_user';
import type {WithDatabaseArgs} from '@typings/database/database';
const withTeamId = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUserId: observeCurrentUserId(database),
currentTeamId: observeCurrentTeamId(database),
}));

View file

@ -63,7 +63,6 @@ describe('SelectUser', () => {
function getBaseProps(): ComponentProps<typeof SelectUser> {
return {
currentTeamId: 'team-1',
currentUserId: 'current-user',
handleSelect: jest.fn(),
componentId: 'PlaybookSelectUser',
participantIds: ['participant-1', 'participant-2'],
@ -83,7 +82,6 @@ describe('SelectUser', () => {
expect(queryByTestId('button')).toBeNull();
const userList = getByTestId('integration_selector.user_list');
expect(userList).toHaveProp('currentUserId', props.currentUserId);
expect(userList).toHaveProp('term', '');
expect(userList).toHaveProp('tutorialWatched', true);
expect(userList).toHaveProp('handleSelectProfile', expect.any(Function));

View file

@ -29,7 +29,6 @@ const close = () => {
export type Props = {
currentTeamId: string;
currentUserId: string;
handleSelect: (opt: UserProfile) => void;
handleRemove?: () => void;
selected?: string;
@ -93,7 +92,6 @@ function SelectUser({
handleSelect,
handleRemove,
currentTeamId,
currentUserId,
componentId,
participantIds,
}: Props) {
@ -251,7 +249,6 @@ function SelectUser({
)}
</View>
<ServerUserList
currentUserId={currentUserId}
term={term}
tutorialWatched={true}
handleSelectProfile={handleSelectProfile}

View file

@ -63,7 +63,6 @@ export const getHeaderOptions = async (theme: Theme, displayName: string, inModa
type Props = {
componentId: AvailableScreens;
channel?: ChannelModel;
currentUserId: string;
teammateNameDisplay: string;
tutorialWatched: boolean;
inModal?: boolean;
@ -119,7 +118,6 @@ function removeProfileFromList(list: Set<string>, id: string) {
export default function ChannelAddMembers({
componentId,
channel,
currentUserId,
teammateNameDisplay,
tutorialWatched,
inModal,
@ -303,7 +301,6 @@ export default function ChannelAddMembers({
/>
</View>
<ServerUserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
selectedIds={selectedIds}
term={term}

View file

@ -305,7 +305,6 @@ export default function CreateDirectMessage({
/>
</View>
<ServerUserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
selectedIds={selectedIds}
term={term}

View file

@ -2,14 +2,13 @@
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeCurrentTeamId} from '@queries/servers/system';
import IntegrationSelector from './integration_selector';
import type {WithDatabaseArgs} from '@typings/database/database';
const withTeamId = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUserId: observeCurrentUserId(database),
currentTeamId: observeCurrentTeamId(database),
}));

View file

@ -114,7 +114,6 @@ export type Props = {
getDynamicOptions?: (userInput?: string) => Promise<DialogOption[]>;
options?: PostActionOption[];
currentTeamId: string;
currentUserId: string;
data?: DataTypeList;
dataSource: string;
handleSelect: (opt: Selection) => void;
@ -177,7 +176,7 @@ const messages = defineMessages({
function IntegrationSelector(
{dataSource, data, isMultiselect = false, selected, handleSelect,
currentTeamId, currentUserId, componentId, getDynamicOptions, options}: Props) {
currentTeamId, componentId, getDynamicOptions, options}: Props) {
const serverUrl = useServerUrl();
const theme = useTheme();
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
@ -576,7 +575,6 @@ function IntegrationSelector(
case ViewConstants.DATA_SOURCE_USERS:
return (
<ServerUserList
currentUserId={currentUserId}
term={term}
tutorialWatched={true}
handleSelectProfile={handleSelectProfile}

View file

@ -108,6 +108,8 @@ export default function ManageChannelMembers({
const [term, setTerm] = useState('');
const [searchedTerm, setSearchedTerm] = useState('');
const hasTerm = Boolean(term);
const clearSearch = useCallback(() => {
setTerm('');
setSearchResults(EMPTY);
@ -123,10 +125,6 @@ export default function ManageChannelMembers({
useAndroidHardwareBackHandler(componentId, close);
const handleSelectProfile = useCallback(async (profile: UserProfile) => {
if (profile.id === currentUserId && isManageMode) {
return;
}
if (profile.id !== currentUserId) {
await fetchUsersByIds(serverUrl, [profile.id]);
}
@ -191,7 +189,7 @@ export default function ManageChannelMembers({
text: formatMessage(manage ? messages.button_done : messages.button_manage),
}],
});
}, [theme.sidebarHeaderTextColor]);
}, [componentId, formatMessage, theme.sidebarHeaderTextColor]);
const toggleManageEnabled = useCallback(() => {
updateNavigationButtons(!isManageMode);
@ -240,11 +238,11 @@ export default function ManageChannelMembers({
}, [searchResults, profiles, searchedTerm, sortedProfiles]);
useEffect(() => {
if (!term) {
if (!hasTerm) {
setSearchResults(EMPTY);
setSearchedTerm('');
}
}, [Boolean(term)]);
}, [hasTerm]);
useNavButtonPressed(MANAGE_BUTTON, componentId, toggleManageEnabled, [toggleManageEnabled]);
@ -283,12 +281,19 @@ export default function ManageChannelMembers({
return () => {
mounted.current = false;
};
// This effect is used only to track the mounted state and the initial fetch
// so it should only run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (canManageAndRemoveMembers) {
updateNavigationButtons(false);
}
// We only want to update the navigation buttons when the permission changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canManageAndRemoveMembers]);
useEffect(() => {
@ -334,7 +339,6 @@ export default function ManageChannelMembers({
/>
</View>
<UserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
loading={loading}
manageMode={true} // default true to change row select icon to a dropdown

View file

@ -26,6 +26,7 @@ import GlobalThreadsScreen from './global_threads';
import HomeScreen from './home';
import Invite from './invite';
import LoginScreen from './login';
import ManageChannelMembersScreen from './manage_channel_members';
import MentionNotificationSettingsScreen from './mention_notification_settings';
import NotificationSettingsScreen from './notification_settings';
import PermalinkScreen from './permalink';
@ -74,6 +75,7 @@ export {
HomeScreen,
Invite,
LoginScreen,
ManageChannelMembersScreen,
MentionNotificationSettingsScreen,
NotificationSettingsScreen,
PermalinkScreen,

View file

@ -0,0 +1,98 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ProfilePicture} from '@support/ui/component';
import {ChannelInfoScreen} from '@support/ui/screen';
import {isIos, timeouts, wait} from '@support/utils';
import {expect, waitFor} from 'detox';
class ManageChannelMembersScreen {
testID = {
backButton: 'screen.back.button',
manageMembersScreen: 'manage_members.screen',
manageDoneButton: 'manage_members.button', // Same button, text changes between "Manage" and "Done"
searchBar: 'manage_members.search_bar',
userList: 'manage_members.user_list',
userItemPrefix: 'create_direct_message.user_list.user_item.',
notice: 'manage_members.notice',
tutorialHighlight: 'tutorial_highlight',
tutorialSwipeLeft: 'tutorial_swipe_left',
};
manageMembersScreen = element(by.id(this.testID.manageMembersScreen));
manageButton = element(by.id(this.testID.manageDoneButton));
doneButton = element(by.id(this.testID.manageDoneButton)); // Same element as manageButton, different text
searchBar = element(by.id(this.testID.searchBar));
userList = element(by.id(this.testID.userList));
notice = element(by.id(this.testID.notice));
tutorialHighlight = element(by.id(this.testID.tutorialHighlight));
tutorialSwipeLeft = element(by.id(this.testID.tutorialSwipeLeft));
backButton = element(by.id(this.testID.backButton));
getUserItem = (userId: string) => {
return element(by.id(`${this.testID.userItemPrefix}${userId}.${userId}`));
};
getUserItemProfilePicture = (userId: string) => {
return element(ProfilePicture.getProfilePictureItemMatcher(this.testID.userItemPrefix, userId));
};
getUserItemDisplayName = (userId: string) => {
return element(by.id(`${this.testID.userItemPrefix}${userId}.${userId}.display_name`));
};
toBeVisible = async () => {
if (isIos()) {
await waitFor(this.manageMembersScreen).toExist().withTimeout(timeouts.TEN_SEC);
}
return this.manageMembersScreen;
};
open = async () => {
// # Open channel info screen and tap on members option
await ChannelInfoScreen.membersOption.tap();
await wait(timeouts.ONE_SEC);
return this.toBeVisible();
};
close = async () => {
await this.backButton.tap();
await expect(this.manageMembersScreen).not.toBeVisible();
};
toggleManageMode = async () => {
// # Tap on manage/done button to toggle manage mode
// The button testID is the same for both states, so we use manageButton
await this.manageButton.tap();
await wait(timeouts.ONE_SEC);
};
exitManageMode = async () => {
// # Tap on done button to exit manage mode
// The button testID is the same for both states, so we use doneButton (which is the same element)
await this.doneButton.tap();
await wait(timeouts.ONE_SEC);
};
closeTutorial = async () => {
try {
if (isIos()) {
await waitFor(this.tutorialHighlight).toExist().withTimeout(timeouts.HALF_MIN);
await this.tutorialSwipeLeft.tap();
await expect(this.tutorialHighlight).not.toExist();
} else {
await wait(timeouts.ONE_SEC);
await device.pressBack();
await wait(timeouts.ONE_SEC);
}
} catch {
// eslint-disable-next-line no-console
console.log('Tutorial element not visible, skipping action:');
}
};
}
const manageChannelMembersScreen = new ManageChannelMembersScreen();
export default manageChannelMembersScreen;

View file

@ -0,0 +1,115 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// *******************************************************************
// - [#] indicates a test step (e.g. # Go to a screen)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element testID when selecting an element. Create one if none.
// *******************************************************************
import {
Channel,
Setup,
} from '@support/server_api';
import {
serverOneUrl,
siteOneUrl,
} from '@support/test_config';
import {
ChannelScreen,
ChannelListScreen,
ChannelInfoScreen,
HomeScreen,
LoginScreen,
ManageChannelMembersScreen,
ServerScreen,
UserProfileScreen,
} from '@support/ui/screen';
import {timeouts, wait} from '@support/utils';
import {expect} from 'detox';
describe('Channels - Manage Own Channel Membership', () => {
const serverOneDisplayName = 'Server 1';
const channelsCategory = 'channels';
let testTeam: any;
let testUser: any;
beforeAll(async () => {
const {team, user} = await Setup.apiInit(siteOneUrl);
testTeam = team;
testUser = user;
// # Log in to server
await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName);
await LoginScreen.login(testUser);
});
beforeEach(async () => {
// * Verify on channel list screen
await ChannelListScreen.toBeVisible();
});
afterAll(async () => {
// # Log out
await HomeScreen.logout();
});
it('MM-66375 - should be able to see and manage own membership in channel members list', async () => {
// # Create a channel and add the test user to it
const {channel} = await Channel.apiCreateChannel(siteOneUrl, {teamId: testTeam.id});
await Channel.apiAddUserToChannel(siteOneUrl, testUser.id, channel.id);
await device.reloadReactNative();
// # Open the channel screen
await ChannelScreen.open(channelsCategory, channel.name);
// # Open channel info screen
await ChannelInfoScreen.open();
// # Open manage channel members screen
await ChannelInfoScreen.scrollView.scrollTo('bottom');
await wait(timeouts.ONE_SEC);
await ManageChannelMembersScreen.open();
// # Close tutorial
await ManageChannelMembersScreen.closeTutorial();
// * Verify manage channel members screen is visible
await ManageChannelMembersScreen.toBeVisible();
// * Verify the current user appears in the members list
await expect(ManageChannelMembersScreen.getUserItemDisplayName(testUser.id)).toBeVisible();
// # Enable manage mode
await ManageChannelMembersScreen.toggleManageMode();
// * Verify manage mode is enabled (done button should be visible)
await expect(ManageChannelMembersScreen.doneButton).toBeVisible();
// * Verify the current user can be selected in manage mode (they should have the manage mode icon visible)
// The manage mode icon (chevron-down) should be visible for the current user
await expect(ManageChannelMembersScreen.getUserItem(testUser.id)).toBeVisible();
// # Tap on the current user in manage mode
await ManageChannelMembersScreen.getUserItem(testUser.id).tap();
// * Verify that tapping on own user in manage mode opens the user profile or shows manage options
// This verifies that the restriction preventing users from managing their own membership has been removed
await UserProfileScreen.toBeVisible();
// # Close user profile screen
await UserProfileScreen.close();
// # Exit manage mode
await ManageChannelMembersScreen.exitManageMode();
// * Verify manage mode is disabled (manage button should be visible)
await expect(ManageChannelMembersScreen.manageButton).toBeVisible();
// # Go back to channel list screen
await ManageChannelMembersScreen.close();
await ChannelInfoScreen.close();
await ChannelScreen.back();
});
});