MM-30289 Detox/E2E - Add e2e tests for MM-T3184 and MM-T3186 (#5133)

* MM-30289 Detox/E2E - Add e2e tests for MM-T3184 and MM-T3186

* Update detox/e2e/support/server_api/preference.js

Co-authored-by: Saturnino Abril <saturnino.abril@gmail.com>

* MM-31322: redirect permalink handler (#5136)

* invert loadTeam.error test

* MM-31322: handle _redirect permalinks

In the webapp, [there is support](7c5a2e3bfd/components/root/root.jsx (L345-L352)) for a special `_redirect` team name in certain contexts that simply means, "the current team". It's used in the context of redirecting to the integrations backstage (not relevant for mobile), for also for resolving post permalinks.

This simplifies certain kinds of integrations which no longer have to know a team name to render a permanent link to a post. An example link looks like:

    http://localhost:8065/_redirect/pl/e3sxrxtwh78jmxawsaqfemxoew

This PR adds support for same in mobile, unintentionally missed on the first round of implementation.

Fixes: https://mattermost.atlassian.net/browse/MM-31322

* Apply suggestions from code review

Co-authored-by: Joseph Baylon <joseph.baylon@mattermost.com>

* linting issues

* Apply suggestions from code review

Co-authored-by: Joseph Baylon <joseph.baylon@mattermost.com>

* revert showPermalink changes

* Revert "invert loadTeam.error test"

This reverts commit accf6c8fb7c56009a393a6e85084d4702deec9ed.

* support _redirect deeplinks

* Apply suggestions from code review

* Update app/components/post_list/index.js

Co-authored-by: Joseph Baylon <joseph.baylon@mattermost.com>

Co-authored-by: Saturnino Abril <saturnino.abril@gmail.com>
Co-authored-by: Jesse Hallam <jesse.hallam@gmail.com>
This commit is contained in:
Joseph Baylon 2021-02-10 13:55:00 -08:00 committed by GitHub
parent cf7731e30f
commit f495e1e48d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 329 additions and 5 deletions

View file

@ -5,6 +5,7 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {getConfig, getCurrentUrl} from '@mm-redux/selectors/entities/general';
import {getCurrentTeam} from '@mm-redux/selectors/entities/teams';
import {handleSelectChannelByName} from 'app/actions/views/channel';
import MarkdownLink from './markdown_link';
@ -13,6 +14,7 @@ function mapStateToProps(state) {
return {
serverURL: getCurrentUrl(state),
siteURL: getConfig(state).SiteURL,
currentTeamName: getCurrentTeam(state)?.name,
};
}

View file

@ -15,7 +15,7 @@ import {getCurrentServerUrl} from '@init/credentials';
import BottomSheet from '@utils/bottom_sheet';
import {errorBadChannel} from '@utils/draft';
import {preventDoubleTap} from '@utils/tap';
import {matchDeepLink, normalizeProtocol, tryOpenURL} from '@utils/url';
import {matchDeepLink, normalizeProtocol, tryOpenURL, PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url';
import mattermostManaged from 'app/mattermost_managed';
@ -29,6 +29,7 @@ export default class MarkdownLink extends PureComponent {
onPermalinkPress: PropTypes.func,
serverURL: PropTypes.string,
siteURL: PropTypes.string.isRequired,
currentTeamName: PropTypes.string,
};
static defaultProps = {
@ -61,7 +62,11 @@ export default class MarkdownLink extends PureComponent {
const {intl} = this.context;
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel.bind(null, intl));
} else if (match.type === DeepLinkTypes.PERMALINK) {
onPermalinkPress(match.postId, match.teamName);
if (match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) {
onPermalinkPress(match.postId, this.props.currentTeamName);
} else {
onPermalinkPress(match.postId, match.teamName);
}
}
} else {
const onError = () => {

View file

@ -8,6 +8,7 @@ import {closePermalink, showPermalink} from '@actions/views/permalink';
import {getConfig, getCurrentUrl} from '@mm-redux/selectors/entities/general';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {makePreparePostIdsForPostList, START_OF_NEW_MESSAGES} from '@mm-redux/utils/post_list';
import {getCurrentTeam} from '@mm-redux/selectors/entities/teams';
import {handleSelectChannelByName, refreshChannelWithRetry} from 'app/actions/views/channel';
import {setDeepLinkURL} from 'app/actions/views/root';
@ -30,6 +31,7 @@ function makeMapStateToProps() {
serverURL: getCurrentUrl(state),
siteURL: getConfig(state).SiteURL,
theme: getTheme(state),
currentTeamName: getCurrentTeam(state)?.name,
};
};
}

View file

@ -16,7 +16,7 @@ import Post from 'app/components/post';
import {DeepLinkTypes, ListTypes, NavigationTypes} from '@constants';
import mattermostManaged from 'app/mattermost_managed';
import {makeExtraData} from 'app/utils/list_view';
import {matchDeepLink} from 'app/utils/url';
import {matchDeepLink, PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from 'app/utils/url';
import telemetry from 'app/telemetry';
import DateHeader from './date_header';
@ -71,6 +71,7 @@ export default class PostList extends PureComponent {
location: PropTypes.string,
scrollViewNativeID: PropTypes.string,
showMoreMessagesButton: PropTypes.bool,
currentTeamName: PropTypes.string,
};
static defaultProps = {
@ -81,6 +82,7 @@ export default class PostList extends PureComponent {
siteURL: '',
postIds: [],
showMoreMessagesButton: false,
currentTeamName: '',
};
static contextTypes = {
@ -181,7 +183,7 @@ export default class PostList extends PureComponent {
};
handleDeepLink = (url) => {
const {serverURL, siteURL} = this.props;
const {serverURL, siteURL, currentTeamName} = this.props;
const match = matchDeepLink(url, serverURL, siteURL);
@ -190,7 +192,11 @@ export default class PostList extends PureComponent {
const {intl} = this.context;
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel(intl));
} else if (match.type === DeepLinkTypes.PERMALINK) {
this.handlePermalinkPress(match.postId, match.teamName);
if (match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) {
this.handlePermalinkPress(match.postId, currentTeamName);
} else {
this.handlePermalinkPress(match.postId, match.teamName);
}
}
} else {
const {formatMessage} = this.context.intl;

View file

@ -102,6 +102,8 @@ export function getScheme(url) {
return match && match[1];
}
export const PERMALINK_GENERIC_TEAM_NAME_REDIRECT = '_redirect';
export function matchDeepLink(url, serverURL, siteURL) {
if (!url || (!serverURL && !siteURL)) {
return null;

View file

@ -40,6 +40,42 @@ export const apiCreateChannel = async ({teamId = null, type = 'O', prefix = 'cha
}
};
/**
* Create a direct message channel.
* See https://api.mattermost.com/#tag/channels/paths/~1channels~1direct/post
* @param {Array} userIds - the two user IDs to be in the direct message
*/
export const apiCreateDirectChannel = async (userIds = []) => {
try {
const response = await client.post(
'/api/v4/channels/direct',
userIds,
);
return {channel: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Create a group message channel.
* See https://api.mattermost.com/#tag/channels/paths/~1channels~1group/post
* @param {Array} userIds - user IDs to be in the group message channel
*/
export const apiCreateGroupChannel = async (userIds = []) => {
try {
const response = await client.post(
'/api/v4/channels/group',
userIds,
);
return {channel: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Get a channel by name and team name.
* See https://api.mattermost.com/#tag/channels/paths/~1teams~1name~1{team_name}~1channels~1name~1{channel_name}/get
@ -129,6 +165,8 @@ function generateRandomChannel(teamId, type, prefix) {
export const Channel = {
apiAddUserToChannel,
apiCreateChannel,
apiCreateDirectChannel,
apiCreateGroupChannel,
apiDeleteUserFromChannel,
apiGetChannelByName,
apiGetChannelsForUser,

View file

@ -35,6 +35,23 @@ export const apiSaveUserPreferences = async (userId, preferences = []) => {
}
};
/**
* Save the user's favorite channel preference.
* @param {string} userId - the user ID
* @param {string} channelId - the channel id to be favorited
* @return {string} returns {status} on success or {error, status} on error
*/
export const apiSaveFavoriteChannelPreference = (userId, channelId) => {
const preference = {
user_id: userId,
category: 'favorite_channel',
name: channelId,
value: 'true',
};
return apiSaveUserPreferences(userId, [preference]);
};
/**
* Save the user's teams order preference.
* @param {string} userId - the user ID
@ -54,6 +71,7 @@ export const apiSaveTeamsOrderPreference = (userId, orderedTeamIds = []) => {
export const Preference = {
apiSaveUserPreferences,
apiSaveFavoriteChannelPreference,
apiSaveTeamsOrderPreference,
};

View file

@ -1,8 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import SearchBar from './search_bar';
class ChannelsList {
testID = {
channelsListPrefix: 'main.sidebar.channels_list.',
channelsList: 'main.sidebar.channels_list.list',
channelItem: 'main.sidebar.channels_list.list.channel_item',
channelItemDisplayName: 'main.sidebar.channels_list.list.channel_item.display_name',
@ -22,6 +25,12 @@ class ChannelsList {
switchTeamsButtonBadgeUnreadCount = element(by.id(this.testID.switchTeamsButtonBadgeUnreadCount));
switchTeamsButtonBadgeUnreadIndicator = element(by.id(this.testID.switchTeamsButtonBadgeUnreadIndicator));
// convenience props
searchBar = SearchBar.getSearchBar(this.testID.channelsListPrefix);
searchInput = SearchBar.getSearchInput(this.testID.channelsListPrefix);
cancelButton = SearchBar.getCancelButton(this.testID.channelsListPrefix);
clearButton = SearchBar.getClearButton(this.testID.channelsListPrefix);
getChannelItem = (channelId, displayName) => {
const channelItemTestID = `${this.testID.channelItem}.${channelId}`;
const baseMatcher = by.id(channelItemTestID);

View file

@ -18,6 +18,10 @@ class MainSidebar {
openMoreDirectMessagesButton = element(by.id(this.testID.openMoreDirectMessagesButton));
// convenience props
searchBar = ChannelsList.searchBar;
searchInput = ChannelsList.searchInput;
cancelButton = ChannelsList.cancelButton;
clearButton = ChannelsList.clearButton;
channelsList = ChannelsList.channelsList;
filteredChannelsList = ChannelsList.filteredChannelsList;
switchTeamsButton = ChannelsList.switchTeamsButton;

View file

@ -0,0 +1,87 @@
// 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 {MainSidebar} from '@support/ui/component';
import {ChannelScreen, PermalinkScreen} from '@support/ui/screen';
import {User, Setup, Channel, Post} from '@support/server_api';
describe('Permalink', () => {
let testUser;
let testChannel;
let townSquareChannel;
beforeAll(async () => {
const {user, team, channel} = await Setup.apiInit();
testUser = user;
testChannel = channel;
({channel: townSquareChannel} = await Channel.apiGetChannelByName(team.name, 'town-square'));
// # Open channel screen
await ChannelScreen.open(user);
});
afterAll(async () => {
await ChannelScreen.logout();
});
const expectPermalinkTargetMessage = async (permalinkTargetPost, permalinkTargetChannel) => {
// # Post a message in the test channel referencing the given permalink.
const permalinkLabel = `permalink-${Date.now().toString()}`;
const permalinkMessage = `[${permalinkLabel}](/_redirect/pl/${permalinkTargetPost.id})`;
await Post.apiCreatePost({
channelId: testChannel.id,
message: permalinkMessage,
});
// # Go to test channel
await ChannelScreen.openMainSidebar();
await MainSidebar.getChannelByDisplayName(testChannel.display_name).tap();
await ChannelScreen.toBeVisible();
// # Tap the channel permalink
await element(by.text(permalinkLabel)).tap({x: 5, y: 10});
// * Verify permalink post list has the expected target message
await PermalinkScreen.toBeVisible();
const {postListPostItem: permalinkPostItem} = await PermalinkScreen.getPostListPostItem(permalinkTargetPost.id, permalinkTargetPost.message);
await expect(permalinkPostItem).toBeVisible();
// # Dismiss the permalink screen by jumping to recent messages
await PermalinkScreen.jumpToRecentMessages();
// * Verify user is on channel where message is posted
await expect(ChannelScreen.channelNavBarTitle).toHaveText(permalinkTargetChannel.display_name);
const {postListPostItem: channelPostItem} = await ChannelScreen.getPostListPostItem(permalinkTargetPost.id, permalinkTargetPost.message);
await expect(channelPostItem).toBeVisible();
};
it('MM-T3805_1 should support _redirect to public channel post', async () => {
// # Post a test message in a public channel
const permalinkTargetMessage = 'post in Town Square';
const permalinkTargetPost = await Post.apiCreatePost({
channelId: townSquareChannel.id,
message: permalinkTargetMessage,
});
await expectPermalinkTargetMessage(permalinkTargetPost.post, townSquareChannel);
});
it('MM-T3805_2 should support _redirect to DM post', async () => {
// # Post a test message in a DM
const {user: dmOtherUser} = await User.apiCreateUser({prefix: 'testchannel-1'});
const {channel: directMessageChannel} = await Channel.apiCreateDirectChannel([testUser.id, dmOtherUser.id]);
const permalinkTargetMessage = 'post in DM';
const permalinkTargetPost = await Post.apiCreatePost({
channelId: directMessageChannel.id,
message: permalinkTargetMessage,
});
await expectPermalinkTargetMessage(permalinkTargetPost.post, directMessageChannel);
});
});

View file

@ -0,0 +1,151 @@
// 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 {MainSidebar} from '@support/ui/component';
import {ChannelScreen} from '@support/ui/screen';
import {
Channel,
Post,
Preference,
Setup,
Team,
User,
} from '@support/server_api';
describe('Channels', () => {
let testMessage;
let unreadChannel;
let favoriteChannel;
let publicChannel;
let privateChannel;
let nonJoinedChannel;
let directMessageChannel;
let dmOtherUser;
let nonDmOtherUser;
const {
channelNavBarTitle,
closeMainSidebar,
openMainSidebar,
} = ChannelScreen;
const {
getChannelByDisplayName,
getFilteredChannelByDisplayName,
hasChannelDisplayNameAtIndex,
hasFilteredChannelDisplayNameAtIndex,
searchInput,
} = MainSidebar;
beforeAll(async () => {
const {user, channel, team} = await Setup.apiInit();
unreadChannel = channel;
testMessage = `Mention @${user.username}`;
await Post.apiCreatePost({
channelId: unreadChannel.id,
message: testMessage,
});
({channel: favoriteChannel} = await Channel.apiCreateChannel({type: 'O', prefix: '4-favorite-channel', teamId: team.id}));
await Channel.apiAddUserToChannel(user.id, favoriteChannel.id);
await Preference.apiSaveFavoriteChannelPreference(user.id, favoriteChannel.id);
({channel: publicChannel} = await Channel.apiCreateChannel({type: 'O', prefix: '3-public-channel', teamId: team.id}));
await Channel.apiAddUserToChannel(user.id, publicChannel.id);
({channel: privateChannel} = await Channel.apiCreateChannel({type: 'P', prefix: '2-private-channel', teamId: team.id}));
await Channel.apiAddUserToChannel(user.id, privateChannel.id);
({channel: nonJoinedChannel} = await Channel.apiCreateChannel({type: 'O', prefix: '1-non-joined-channel', teamId: team.id}));
({user: dmOtherUser} = await User.apiCreateUser({prefix: 'testchannel-1'}));
({channel: directMessageChannel} = await Channel.apiCreateDirectChannel([user.id, dmOtherUser.id]));
await Post.apiCreatePost({
channelId: directMessageChannel.id,
message: testMessage,
});
({user: nonDmOtherUser} = await User.apiCreateUser({prefix: 'testchannel-2'}));
await Team.apiAddUserToTeam(nonDmOtherUser.id, team.id);
// # Open channel screen
await ChannelScreen.open(user);
});
afterAll(async () => {
await ChannelScreen.logout();
});
it('MM-T3184 should display unfiltered channels list', async () => {
// # Open main sidebar
await openMainSidebar();
// * Verify order when all channels are unread
await hasChannelDisplayNameAtIndex(0, privateChannel.display_name);
await hasChannelDisplayNameAtIndex(1, publicChannel.display_name);
await hasChannelDisplayNameAtIndex(2, favoriteChannel.display_name);
await hasChannelDisplayNameAtIndex(3, unreadChannel.display_name);
await hasChannelDisplayNameAtIndex(4, dmOtherUser.username);
await hasChannelDisplayNameAtIndex(5, 'Off-Topic');
await hasChannelDisplayNameAtIndex(6, 'Town Square');
await expect(element(by.id(nonJoinedChannel.display_name))).not.toBeVisible();
await expect(element(by.id(nonDmOtherUser.username))).not.toBeVisible();
// # Visit private, public, favorite, and direct message channels
await getChannelByDisplayName(privateChannel.display_name).tap();
await openMainSidebar();
await getChannelByDisplayName(publicChannel.display_name).tap();
await openMainSidebar();
await getChannelByDisplayName(favoriteChannel.display_name).tap();
await openMainSidebar();
await getChannelByDisplayName(dmOtherUser.username).tap();
await openMainSidebar();
// * Verify order when all channels are read except for unread channel
await hasChannelDisplayNameAtIndex(0, unreadChannel.display_name);
await hasChannelDisplayNameAtIndex(1, favoriteChannel.display_name);
await hasChannelDisplayNameAtIndex(2, publicChannel.display_name);
await hasChannelDisplayNameAtIndex(3, 'Off-Topic');
await hasChannelDisplayNameAtIndex(4, 'Town Square');
await hasChannelDisplayNameAtIndex(5, privateChannel.display_name);
await hasChannelDisplayNameAtIndex(6, dmOtherUser.username);
await expect(element(by.id(nonJoinedChannel.display_name))).not.toBeVisible();
await expect(element(by.id(nonDmOtherUser.username))).not.toBeVisible();
// # Close main sidebar
await closeMainSidebar();
});
it('MM-T3186 should display filtered channels list and be able to change channels', async () => {
// # Open main sidebar
await openMainSidebar();
// # Enter search term
await searchInput.typeText('channel');
await searchInput.tapBackspaceKey();
// * Verify order when channels list is filtered
await hasFilteredChannelDisplayNameAtIndex(0, unreadChannel.display_name);
await hasFilteredChannelDisplayNameAtIndex(1, dmOtherUser.username);
await hasFilteredChannelDisplayNameAtIndex(2, privateChannel.display_name);
await hasFilteredChannelDisplayNameAtIndex(3, publicChannel.display_name);
await hasFilteredChannelDisplayNameAtIndex(4, favoriteChannel.display_name);
await hasFilteredChannelDisplayNameAtIndex(5, nonDmOtherUser.username);
await hasFilteredChannelDisplayNameAtIndex(6, nonJoinedChannel.display_name);
await expect(element(by.text('Off-Topic'))).not.toBeVisible();
await expect(element(by.text('Town Square'))).not.toBeVisible();
// # Tap a channel from filtered list
await getFilteredChannelByDisplayName(unreadChannel.display_name).tap();
// * Verify filtered channel opens
await expect(channelNavBarTitle).toHaveText(unreadChannel.display_name);
// # Close main sidebar
await closeMainSidebar();
});
});