diff --git a/e2e-tests/playwright/global_setup.ts b/e2e-tests/playwright/global_setup.ts index 7bd53a3aa7..ea84873dda 100644 --- a/e2e-tests/playwright/global_setup.ts +++ b/e2e-tests/playwright/global_setup.ts @@ -2,7 +2,9 @@ // See LICENSE.txt for license information. import {expect} from '@playwright/test'; + import {UserProfile} from '@mattermost/types/users'; +import { PreferenceType } from '@mattermost/types/preferences'; import {Client, createRandomTeam, getAdminClient, getDefaultAdminUser, makeClient} from './support/server'; import {defaultTeam} from './support/util'; @@ -63,6 +65,9 @@ async function sysadminSetup(client: Client, user: UserProfile | null) { ); } + // Set default preferences + await savePreferences(client, user?.id ?? ''); + // Ensure all products as plugin are installed and active. await ensurePluginsLoaded(client); @@ -184,4 +189,23 @@ async function ensureServerDeployment(client: Client) { } } +async function savePreferences(client: Client, userId: UserProfile['id']) { + try { + if (!userId) { + throw new Error('userId is not defined'); + } + + const preferences: PreferenceType[] = [ + {user_id: userId, category: 'tutorial_step', name: userId, value: '999'}, + {user_id: userId, category: 'drafts', name: 'drafts_tour_tip_showed', value: JSON.stringify({drafts_tour_tip_showed: true})}, + {user_id: userId, category: 'crt_thread_pane_step', name: userId, value: '999'}, + ]; + + await client.savePreferences(userId, preferences); + } catch (error) { + // eslint-disable-next-line no-console + console.log('Error saving preferences', error); + } +} + export default globalSetup; diff --git a/e2e-tests/playwright/support/server/init.ts b/e2e-tests/playwright/support/server/init.ts index 77cd701b6f..f48d59d94a 100644 --- a/e2e-tests/playwright/support/server/init.ts +++ b/e2e-tests/playwright/support/server/init.ts @@ -52,6 +52,8 @@ export async function initSetup({ // Update user preference const preferences: PreferenceType[] = [ {user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'}, + {user_id: user.id, category: 'drafts', name: 'drafts_tour_tip_showed', value: JSON.stringify({drafts_tour_tip_showed: true})}, + {user_id: user.id, category: 'crt_thread_pane_step', name: user.id, value: '999'}, ]; await userClient.savePreferences(user.id, preferences); diff --git a/e2e-tests/playwright/support/server/post.ts b/e2e-tests/playwright/support/server/post.ts new file mode 100644 index 0000000000..514056fad4 --- /dev/null +++ b/e2e-tests/playwright/support/server/post.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Post, PostMetadata} from '@mattermost/types/posts'; +import {getRandomId} from '@e2e-support/util'; + +export function createRandomPost(post?: Partial): Post { + if (post && post.channel_id && post.user_id) { + const time = Date.now(); + + const defaultPost = { + create_at: time, + user_id: post.user_id, + channel_id: post.channel_id, + root_id: post.root_id || '', + message: `${post?.message ?? ''}${getRandomId()}`, + pending_post_id: `${post.user_id}:${time}`, + props: post?.props || {}, + file_ids: post?.file_ids || [], + metadata: {} as PostMetadata, + }; + + Reflect.deleteProperty(post, 'user_id'); + Reflect.deleteProperty(post, 'channel_id'); + Reflect.deleteProperty(post, 'message'); + Reflect.deleteProperty(post, 'pending_post_id'); + + return {...defaultPost, ...post} as Post; + } + + throw new Error('Post is missing channel_id or user_id or both'); +} diff --git a/e2e-tests/playwright/support/ui/components/channels/delete_post_modal.ts b/e2e-tests/playwright/support/ui/components/channels/delete_post_modal.ts new file mode 100644 index 0000000000..041f5a5afa --- /dev/null +++ b/e2e-tests/playwright/support/ui/components/channels/delete_post_modal.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, Locator} from '@playwright/test'; + +export default class DeletePostModal { + readonly container: Locator; + readonly confirmButton: Locator; + + constructor(container: Locator) { + this.container = container; + this.confirmButton = this.container.locator('#deletePostModalButton'); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async confirm() { + await this.confirmButton.waitFor(); + await this.confirmButton.click(); + + // Wait for the modal to disappear + await expect(this.container).not.toBeVisible(); + } +} + +export {DeletePostModal}; diff --git a/e2e-tests/playwright/support/ui/components/channels/post.ts b/e2e-tests/playwright/support/ui/components/channels/post.ts index 341571dd6d..94d0b87fbb 100644 --- a/e2e-tests/playwright/support/ui/components/channels/post.ts +++ b/e2e-tests/playwright/support/ui/components/channels/post.ts @@ -3,25 +3,43 @@ import {expect, Locator} from '@playwright/test'; +import {components} from '@e2e-support/ui/components'; + export default class ChannelsPost { readonly container: Locator; readonly body; readonly profileIcon; - readonly replyButton; + + readonly removePostButton; + + readonly postMenu; + readonly threadFooter; constructor(container: Locator) { this.container = container; this.body = container.locator('.post__body'); + this.profileIcon = container.locator('.profile-icon'); - this.replyButton = container.getByRole('button', {name: 'reply'}); + + this.removePostButton = container.locator('.post__remove'); + + this.postMenu = new components.PostMenu(container.locator('.post-menu')); + this.threadFooter = new components.ThreadFooter(container.locator('.ThreadFooter')); } async toBeVisible() { await expect(this.container).toBeVisible(); } + /** + * Hover over the post. Can be used for post menu to appear. + */ + async hover() { + await this.container.hover(); + } + async getId() { const id = await this.container.getAttribute('id'); expect(id, 'No post ID found.').toBeTruthy(); @@ -29,13 +47,21 @@ export default class ChannelsPost { } async getProfileImage(username: string) { - return await this.profileIcon.getByAltText(`${username} profile image`); + return this.profileIcon.getByAltText(`${username} profile image`); } - async openRHS() { + /** + * Clicks on the deleted post's remove 'x' button. + * Also verifies that the post is a deleted post. + */ + async remove() { + // Verify the post is a deleted post + await expect(this.container).toContainText(/\(message deleted\)/); + + // Hover over the post and click on the remove post button await this.container.hover(); - await this.replyButton.waitFor(); - await this.replyButton.click(); + await this.removePostButton.waitFor(); + await this.removePostButton.click(); } } diff --git a/e2e-tests/playwright/support/ui/components/channels/post_create.ts b/e2e-tests/playwright/support/ui/components/channels/post_create.ts index 912fcad80e..b43a213e91 100644 --- a/e2e-tests/playwright/support/ui/components/channels/post_create.ts +++ b/e2e-tests/playwright/support/ui/components/channels/post_create.ts @@ -9,7 +9,7 @@ export default class ChannelsPostCreate { readonly input; readonly attachmentButton; readonly emojiButton; - readonly sendButton: Locator; + readonly sendMessageButton; constructor(container: Locator) { this.container = container; @@ -17,7 +17,7 @@ export default class ChannelsPostCreate { this.input = container.getByTestId('post_textbox'); this.attachmentButton = container.getByLabel('attachment'); this.emojiButton = container.getByLabel('select an emoji'); - this.sendButton = container.getByTestId('SendMessageButton'); + this.sendMessageButton = container.getByTestId('SendMessageButton'); } async postMessage(message: string) { @@ -25,7 +25,7 @@ export default class ChannelsPostCreate { } async sendMessage() { - await this.sendButton.click(); + await this.sendMessageButton.click(); } async toBeVisible() { diff --git a/e2e-tests/playwright/support/ui/components/channels/post_dot_menu.ts b/e2e-tests/playwright/support/ui/components/channels/post_dot_menu.ts new file mode 100644 index 0000000000..30e385f107 --- /dev/null +++ b/e2e-tests/playwright/support/ui/components/channels/post_dot_menu.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, Locator} from '@playwright/test'; + +export default class PostDotMenu { + readonly container: Locator; + + readonly deleteMenuItem; + + constructor(container: Locator) { + this.container = container; + + this.deleteMenuItem = this.container.getByText('Delete', {exact: true}); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async delete() { + await this.deleteMenuItem.waitFor(); + await this.deleteMenuItem.click(); + } +} + +export {PostDotMenu}; diff --git a/e2e-tests/playwright/support/ui/components/channels/post_menu.ts b/e2e-tests/playwright/support/ui/components/channels/post_menu.ts new file mode 100644 index 0000000000..ba97b07b66 --- /dev/null +++ b/e2e-tests/playwright/support/ui/components/channels/post_menu.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, Locator} from '@playwright/test'; + +export default class PostMenu { + readonly container: Locator; + + readonly replyButton; + readonly dotMenuButton; + + constructor(container: Locator) { + this.container = container; + + this.replyButton = container.getByRole('button', {name: 'reply'}); + this.dotMenuButton = container.getByRole('button', {name: 'more'}); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + /** + * Clicks on the reply button from the post menu. + */ + async reply() { + await this.replyButton.waitFor(); + await this.replyButton.click(); + } + + /** + * Clicks on the dot menu button from the post menu. + */ + async openDotMenu() { + await this.dotMenuButton.waitFor(); + await this.dotMenuButton.click(); + } +} + +export {PostMenu}; diff --git a/e2e-tests/playwright/support/ui/components/channels/sidebar_left.ts b/e2e-tests/playwright/support/ui/components/channels/sidebar_left.ts index ba75fa009d..eea28a0d35 100644 --- a/e2e-tests/playwright/support/ui/components/channels/sidebar_left.ts +++ b/e2e-tests/playwright/support/ui/components/channels/sidebar_left.ts @@ -16,6 +16,34 @@ export default class ChannelsSidebarLeft { async toBeVisible() { await expect(this.container).toBeVisible(); } + + /** + * Clicks on the sidebar channel link with the given name. + * It can be any sidebar item name including channels, direct messages, or group messages, threads, etc. + * @param channelName + */ + async goToItem(channelName: string) { + const channel = this.container.locator(`#sidebarItem_${channelName}`); + await channel.waitFor(); + await channel.click(); + } + + /** + * Verifies 'Drafts' as a sidebar link exists in LHS. + */ + async draftsVisible() { + const draftSidebarLink = this.container.getByText('Drafts', {exact: true}); + await draftSidebarLink.waitFor(); + await expect(draftSidebarLink).toBeVisible(); + } + + /** + * Verifies 'Drafts' as a sidebar link does not exist in LHS. + */ + async draftsNotVisible() { + const channel = this.container.getByText('Drafts', {exact: true}); + await expect(channel).not.toBeVisible(); + } } export {ChannelsSidebarLeft}; diff --git a/e2e-tests/playwright/support/ui/components/channels/sidebar_right.ts b/e2e-tests/playwright/support/ui/components/channels/sidebar_right.ts index 76dd0cec54..852d94d6e1 100644 --- a/e2e-tests/playwright/support/ui/components/channels/sidebar_right.ts +++ b/e2e-tests/playwright/support/ui/components/channels/sidebar_right.ts @@ -3,16 +3,62 @@ import {expect, Locator} from '@playwright/test'; +import {components} from '@e2e-support/ui/components'; + export default class ChannelsSidebarRight { readonly container: Locator; + readonly input; + readonly sendMessageButton; + readonly closeButton; + constructor(container: Locator) { this.container = container; + + this.input = container.getByTestId('reply_textbox'); + this.sendMessageButton = container.getByTestId('SendMessageButton'); + + this.closeButton = container.locator('#rhsCloseButton'); } async toBeVisible() { await expect(this.container).toBeVisible(); } + + async postMessage(message: string) { + await this.input.fill(message); + } + + async sendMessage() { + await this.sendMessageButton.click(); + } + + /** + * Returns the value of the textbox in RHS + */ + async getInputValue() { + return await this.input.inputValue(); + } + + /** + * Returns the RHS post by post id + * @param postId Just the ID without the prefix + */ + async getRHSPostById(postId: string) { + const rhsPostId = `rhsPost_${postId}`; + const postLocator = this.container.locator(`#${rhsPostId}`); + return new components.ChannelsPost(postLocator); + } + + /** + * Closes the RHS + */ + async close() { + await this.closeButton.waitFor(); + await this.closeButton.click(); + + await expect(this.container).not.toBeVisible(); + } } export {ChannelsSidebarRight}; diff --git a/e2e-tests/playwright/support/ui/components/channels/thread_footer.ts b/e2e-tests/playwright/support/ui/components/channels/thread_footer.ts new file mode 100644 index 0000000000..310aebe3d3 --- /dev/null +++ b/e2e-tests/playwright/support/ui/components/channels/thread_footer.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, Locator} from '@playwright/test'; + +export default class ThreadFooter { + readonly container: Locator; + + readonly replyButton: Locator; + + constructor(container: Locator) { + this.container = container; + + this.replyButton = container.locator('.ReplyButton'); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + /** + * Clicks on the reply button in the thread footer to open the thread in RHS. + */ + async reply() { + await this.replyButton.waitFor(); + await this.replyButton.click(); + } +} + +export {ThreadFooter}; diff --git a/e2e-tests/playwright/support/ui/components/index.ts b/e2e-tests/playwright/support/ui/components/index.ts index 4aba6c2cd9..ebb3e3b879 100644 --- a/e2e-tests/playwright/support/ui/components/index.ts +++ b/e2e-tests/playwright/support/ui/components/index.ts @@ -11,6 +11,10 @@ import {ChannelsSidebarLeft} from './channels/sidebar_left'; import {ChannelsSidebarRight} from './channels/sidebar_right'; import {FindChannelsModal} from './channels/find_channels_modal'; import {GlobalHeader} from './global_header'; +import {PostDotMenu} from './channels/post_dot_menu'; +import {DeletePostModal} from './channels/delete_post_modal'; +import {PostMenu} from './channels/post_menu'; +import {ThreadFooter} from './channels/thread_footer'; const components = { BoardsSidebar, @@ -23,6 +27,10 @@ const components = { ChannelsSidebarRight, FindChannelsModal, GlobalHeader, + PostDotMenu, + DeletePostModal, + PostMenu, + ThreadFooter, }; export { @@ -37,4 +45,8 @@ export { ChannelsSidebarRight, FindChannelsModal, GlobalHeader, + PostDotMenu, + DeletePostModal, + PostMenu, + ThreadFooter, }; diff --git a/e2e-tests/playwright/support/ui/pages/channels.ts b/e2e-tests/playwright/support/ui/pages/channels.ts index 542f9a822d..5ec00ae40b 100644 --- a/e2e-tests/playwright/support/ui/pages/channels.ts +++ b/e2e-tests/playwright/support/ui/pages/channels.ts @@ -18,6 +18,8 @@ export default class ChannelsPage { readonly appBar; readonly sidebarLeft; readonly sidebarRight; + readonly postDotMenu; + readonly deletePostModal; constructor(page: Page) { this.page = page; @@ -29,6 +31,8 @@ export default class ChannelsPage { this.appBar = new components.ChannelsAppBar(page.locator('.app-bar')); this.sidebarLeft = new components.ChannelsSidebarLeft(page.locator('#SidebarContainer')); this.sidebarRight = new components.ChannelsSidebarRight(page.locator('#sidebar-right')); + this.postDotMenu = new components.PostDotMenu(page.getByRole('menu', {name: 'Post extra options'})); + this.deletePostModal = new components.DeletePostModal(page.locator('#deletePostModal')); } async goto(teamName = '', channelName = '') { diff --git a/e2e-tests/playwright/tests/functional/channels/drafts/drafts_on_deleted_message.spec.ts b/e2e-tests/playwright/tests/functional/channels/drafts/drafts_on_deleted_message.spec.ts new file mode 100644 index 0000000000..3d61f80b07 --- /dev/null +++ b/e2e-tests/playwright/tests/functional/channels/drafts/drafts_on_deleted_message.spec.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '@e2e-support/test_fixture'; +import {createRandomPost} from '@e2e-support/server/post'; + +test('MM-T5435_1 Global Drafts link in sidebar should be hidden when another user deleted root post and user removes the deleted post ', async ({ + pw, + pages, +}) => { + const {adminClient, team, adminUser, user} = await pw.initSetup(); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Get the default channel of the team for getting the channel id + const channel = await adminClient.getChannelByName(team.id, 'town-square'); + + // # Create a post in the channel by admin + const adminPost = await adminClient.createPost( + createRandomPost({ + channel_id: channel.id, + user_id: adminUser.id, + }) + ); + + // # Log in as user in new browser context + const {page} = await pw.testBrowser.login(user); + + // # Visit default channel page + const channelPage = new pages.ChannelsPage(page); + await channelPage.goto(); + await channelPage.toBeVisible(); + + const lastPostByAdmin = await channelPage.getLastPost(); + await lastPostByAdmin.toBeVisible(); + + // # Open the last post sent by admin in RHS + await lastPostByAdmin.hover(); + await lastPostByAdmin.postMenu.toBeVisible(); + await lastPostByAdmin.postMenu.reply(); + + // # Write a message as a user + const sidebarRight = channelPage.sidebarRight; + await sidebarRight.toBeVisible(); + await sidebarRight.postMessage('Replying to a thread'); + await sidebarRight.sendMessage(); + + // # Write a message in the reply thread but don't send it now so that it becomes a draft + const draftMessageByUser = 'I should be in drafts by User'; + await sidebarRight.postMessage(draftMessageByUser); + + // # Close the RHS for draft to be saved + await sidebarRight.close() + + // * Verify drafts link in channel sidebar is visible + await channelPage.sidebarLeft.draftsVisible(); + + // # Delete the last post by admin + try { + await adminClient.deletePost(adminPost.id); + } catch (error) { + throw new Error('Failed to delete post by admin'); + } + + // # Open the last post in the channel sent by admin again + await lastPostByAdmin.threadFooter.reply(); + + // * Verify drafts in user's textbox is still visible + const rhsTextboxValue = await sidebarRight.getInputValue(); + expect(rhsTextboxValue).toBe(draftMessageByUser); + + // # Click on remove post + const deletedPostByAdminInRHS = await sidebarRight.getRHSPostById(adminPost.id); + await deletedPostByAdminInRHS.remove(); + + // * Verify the drafts links should also be removed from sidebar + await channelPage.sidebarLeft.draftsNotVisible(); +}); + +test('MM-T5435_2 Global Drafts link in sidebar should be hidden when user deletes root post ', async ({pw, pages}) => { + const {user} = await pw.initSetup(); + + // # Log in as user in new browser context + const {page} = await pw.testBrowser.login(user); + + // # Visit default channel page + const channelPage = new pages.ChannelsPage(page); + await channelPage.goto(); + await channelPage.toBeVisible(); + + // # Post a message in the channel + await channelPage.postMessage('Message which will be deleted'); + await channelPage.sendMessage(); + + // # Start a thread by clicking on reply menuitem from post options menu + const post = await channelPage.getLastPost(); + await post.hover(); + await post.postMenu.toBeVisible(); + await post.postMenu.reply(); + + const sidebarRight = channelPage.sidebarRight; + await sidebarRight.toBeVisible(); + + // # Write a message in the thread + await sidebarRight.postMessage('Replying to a thread'); + await sidebarRight.sendMessage(); + + // # Write a message in the reply thread but don't send it + await sidebarRight.postMessage('I should be in drafts'); + + // # Close the RHS for draft to be saved + await sidebarRight.close() + + // * Verify drafts link in channel sidebar is visible + await channelPage.sidebarLeft.draftsVisible(); + + // # Click on the dot menu of the post and select delete + await post.hover(); + await post.postMenu.toBeVisible(); + await post.postMenu.openDotMenu(); + await channelPage.postDotMenu.toBeVisible() + await channelPage.postDotMenu.delete(); + + // # Confirm the delete from the modal + await channelPage.deletePostModal.toBeVisible(); + await channelPage.deletePostModal.confirm(); + + // * Verify drafts link in channel sidebar is visible + await channelPage.sidebarLeft.draftsNotVisible(); +});