diff --git a/e2e-tests/playwright/support/ui/components/channels/center_view.ts b/e2e-tests/playwright/support/ui/components/channels/center_view.ts index 13e8408dc9..92f0c65770 100644 --- a/e2e-tests/playwright/support/ui/components/channels/center_view.ts +++ b/e2e-tests/playwright/support/ui/components/channels/center_view.ts @@ -12,12 +12,26 @@ export default class ChannelsCenterView { readonly header; readonly postCreate; + readonly scheduledDraftOptions; + readonly scheduledDraftChannelInfo; + readonly scheduledDraftChannelIcon; + readonly scheduledDraftChannelInfoMessage; + readonly scheduledDraftChannelInfoMessageText; + readonly scheduledDraftSeeAllLink; constructor(container: Locator) { this.container = container; this.header = new components.ChannelsHeader(this.container.locator('.channel-header')); this.postCreate = new components.ChannelsPostCreate(container.getByTestId('post-create')); + this.scheduledDraftOptions = new components.ChannelsPostCreate( + container.locator('#dropdown_send_post_options'), + ); + this.scheduledDraftChannelInfo = container.locator('div.postBoxIndicator'); + this.scheduledDraftChannelIcon = container.locator('#create_post i.icon-draft-indicator'); + this.scheduledDraftChannelInfoMessage = container.locator('div.ScheduledPostIndicator span'); + this.scheduledDraftChannelInfoMessageText = container.locator('span:has-text("Message scheduled for")'); + this.scheduledDraftSeeAllLink = container.locator('a:has-text("See all scheduled messages")'); } async toBeVisible() { @@ -25,6 +39,14 @@ export default class ChannelsCenterView { await this.postCreate.toBeVisible(); } + /** + * Click on "See all scheduled messages" + */ + async clickOnSeeAllscheduledDrafts() { + await this.scheduledDraftSeeAllLink.isVisible(); + await this.scheduledDraftSeeAllLink.click(); + } + /** * Return the first post in the Center */ @@ -86,6 +108,13 @@ export default class ChannelsCenterView { {timeout}, ); } + + async verifyscheduledDraftChannelInfo() { + await this.scheduledDraftChannelInfo.isVisible(); + await this.scheduledDraftChannelIcon.isVisible(); + const messageLocator = this.scheduledDraftChannelInfoMessage.first(); + await expect(messageLocator).toContainText('Message scheduled for'); + } } export {ChannelsCenterView}; 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 14463e3028..42cb5f4872 100644 --- a/e2e-tests/playwright/support/ui/components/channels/post_create.ts +++ b/e2e-tests/playwright/support/ui/components/channels/post_create.ts @@ -5,12 +5,12 @@ import {expect, Locator} from '@playwright/test'; export default class ChannelsPostCreate { readonly container: Locator; - readonly input; readonly attachmentButton; readonly emojiButton; readonly sendMessageButton; + readonly scheduleDraftMessageButton; constructor(container: Locator, isRHS = false) { this.container = container; @@ -24,6 +24,7 @@ export default class ChannelsPostCreate { this.attachmentButton = container.getByLabel('attachment'); this.emojiButton = container.getByLabel('select an emoji'); this.sendMessageButton = container.getByTestId('SendMessageButton'); + this.scheduleDraftMessageButton = container.getByLabel('Schedule message'); } async toBeVisible() { @@ -66,6 +67,18 @@ export default class ChannelsPostCreate { await this.sendMessageButton.click(); } + /** + * Click on Scheduled Draft button to open options + */ + async clickOnScheduleDraftDropdownButton() { + await expect(this.input).toBeVisible(); + + await expect(this.scheduleDraftMessageButton).toBeVisible(); + await expect(this.scheduleDraftMessageButton).toBeEnabled(); + + await this.scheduleDraftMessageButton.click(); + } + /** * Composes and sends a message */ diff --git a/e2e-tests/playwright/support/ui/components/channels/scheduled_draft_menu.ts b/e2e-tests/playwright/support/ui/components/channels/scheduled_draft_menu.ts new file mode 100644 index 0000000000..a8a23b7096 --- /dev/null +++ b/e2e-tests/playwright/support/ui/components/channels/scheduled_draft_menu.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, Locator} from '@playwright/test'; + +export default class ScheduledDraftMenu { + readonly container: Locator; + + readonly scheduleDraftMessageCustomTimeOption; + + constructor(container: Locator) { + this.container = container; + + this.scheduleDraftMessageCustomTimeOption = container.getByText('Choose a custom time'); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async selectCustomTime() { + await this.scheduleDraftMessageCustomTimeOption.click(); + } +} + +export {ScheduledDraftMenu}; diff --git a/e2e-tests/playwright/support/ui/components/channels/scheduled_draft_modal.ts b/e2e-tests/playwright/support/ui/components/channels/scheduled_draft_modal.ts new file mode 100644 index 0000000000..0b8d6a6899 --- /dev/null +++ b/e2e-tests/playwright/support/ui/components/channels/scheduled_draft_modal.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, Locator} from '@playwright/test'; + +export default class ScheduledDraftModal { + readonly container: Locator; + + readonly confirmButton; + readonly dateInput; + readonly timeLocator; + readonly timeDropdownOptions; + + constructor(container: Locator) { + this.container = container; + + this.confirmButton = container.locator('button.confirm'); + this.dateInput = container.locator('div.Input_wrapper'); + this.timeLocator = container.locator('div.dateTime__input'); + this.timeDropdownOptions = container.locator('ul.dropdown-menu .MenuItem'); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + getDaySuffix(day: number): string { + if (day > 3 && day < 21) return 'th'; + switch (day % 10) { + case 1: + return 'st'; + case 2: + return 'nd'; + case 3: + return 'rd'; + default: + return 'th'; + } + } + + dateLocator(day: number, month: string, dayOfWeek: string) { + const daySuffix = this.getDaySuffix(day); + return this.container.locator(`button[aria-label*='${day}${daySuffix} ${month} (${dayOfWeek})']`); + } + + async selectDay(dayFromToday: number = 0) { + await this.dateInput.click(); + + const pacificDate = this.getPacificDate(); + + // If dayFromToday is provided, add days to the current date + if (dayFromToday) { + pacificDate.setDate(pacificDate.getDate() + dayFromToday); + } + + const day = pacificDate.getDate(); + const month = pacificDate.toLocaleString('default', {month: 'long'}); + const dayOfWeek = pacificDate.toLocaleDateString('en-US', {weekday: 'long'}); + + await this.dateLocator(day, month, dayOfWeek).click(); + } + + async confirm() { + await this.confirmButton.isVisible(); + await this.confirmButton.click(); + } + + /** + * Selecting the First time option from the dropdown for + * scheduled_post_job to send the drafts out + */ + async selectTime() { + await this.timeLocator.click(); + const timeButton = this.timeDropdownOptions.first(); + await expect(timeButton).toBeVisible(); + await timeButton.click(); + } + + getPacificDate(): Date { + const currentDate = new Date(); + // Convert the current date to Pacific Time + const utcTime = currentDate.getTime() + currentDate.getTimezoneOffset() * 60000; + const pacificOffset = -7 * 60; // Pacific Daylight Time (UTC-07:00) + const pacificTime = new Date(utcTime + pacificOffset * 60000); + + return pacificTime; + } +} + +export {ScheduledDraftModal}; 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 eea28a0d35..9c75aa8dfc 100644 --- a/e2e-tests/playwright/support/ui/components/channels/sidebar_left.ts +++ b/e2e-tests/playwright/support/ui/components/channels/sidebar_left.ts @@ -6,17 +6,24 @@ import {expect, Locator} from '@playwright/test'; export default class ChannelsSidebarLeft { readonly container: Locator; readonly findChannelButton; + readonly scheduledDraftCountonLHS; constructor(container: Locator) { this.container = container; this.findChannelButton = container.getByRole('button', {name: 'Find Channels'}); + this.scheduledDraftCountonLHS = container.locator('span.scheduledPostBadge'); } async toBeVisible() { await expect(this.container).toBeVisible(); } + async assertscheduledDraftCountLHS(count: string) { + await expect(this.scheduledDraftCountonLHS).toBeVisible(); + await expect(this.scheduledDraftCountonLHS).toHaveText(count); + } + /** * 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. 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 f5a66d1116..41c8a7bf5c 100644 --- a/e2e-tests/playwright/support/ui/components/channels/sidebar_right.ts +++ b/e2e-tests/playwright/support/ui/components/channels/sidebar_right.ts @@ -8,12 +8,22 @@ import {components} from '@e2e-support/ui/components'; export default class ChannelsSidebarRight { readonly container: Locator; - readonly postCreate; readonly closeButton; + readonly postCreate; + readonly rhsPostBody; + readonly scheduledDraftChannelInfo; + readonly scheduledDraftChannelInfoMessage; + readonly scheduledDraftSeeAllLink; + readonly scheduledDraftChannelInfoMessageText; constructor(container: Locator) { this.container = container; + this.scheduledDraftChannelInfo = container.locator('div.postBoxIndicator'); + this.scheduledDraftChannelInfoMessage = container.locator('div.ScheduledPostIndicator span'); + this.scheduledDraftSeeAllLink = container.locator('a:has-text("See all scheduled messages")'); + this.scheduledDraftChannelInfoMessageText = container.locator('span:has-text("Message scheduled for")'); + this.rhsPostBody = container.locator('.post-message__text'); this.postCreate = new components.ChannelsPostCreate(container.getByTestId('comment-create'), true); this.closeButton = container.locator('#rhsCloseButton'); } @@ -50,6 +60,11 @@ export default class ChannelsSidebarRight { await expect(this.container).not.toBeVisible(); } + + async clickOnSeeAllscheduledDrafts() { + await this.scheduledDraftSeeAllLink.isVisible(); + await this.scheduledDraftSeeAllLink.click(); + } } export {ChannelsSidebarRight}; diff --git a/e2e-tests/playwright/support/ui/components/index.ts b/e2e-tests/playwright/support/ui/components/index.ts index d2492c5e90..78c59b8eb7 100644 --- a/e2e-tests/playwright/support/ui/components/index.ts +++ b/e2e-tests/playwright/support/ui/components/index.ts @@ -21,6 +21,8 @@ import {ThreadFooter} from './channels/thread_footer'; import {EmojiGifPicker} from './channels/emoji_gif_picker'; import {GenericConfirmModal} from './channels/generic_confirm_modal'; +import {ScheduledDraftMenu} from './channels/scheduled_draft_menu'; +import {ScheduledDraftModal} from './channels/scheduled_draft_modal'; import {SystemConsoleSidebar} from './system_console/sidebar'; import {SystemConsoleNavbar} from './system_console/navbar'; @@ -49,6 +51,8 @@ const components = { PostReminderMenu, EmojiGifPicker, GenericConfirmModal, + ScheduledDraftMenu, + ScheduledDraftModal, SystemConsoleSidebar, SystemConsoleNavbar, SystemUsers, diff --git a/e2e-tests/playwright/support/ui/pages/channels.ts b/e2e-tests/playwright/support/ui/pages/channels.ts index 27e231b16e..f1330ac97a 100644 --- a/e2e-tests/playwright/support/ui/pages/channels.ts +++ b/e2e-tests/playwright/support/ui/pages/channels.ts @@ -12,6 +12,8 @@ export default class ChannelsPage { readonly globalHeader; readonly centerView; + readonly scheduledDraftDropdown; + readonly scheduledDraftModal; readonly sidebarLeft; readonly sidebarRight; readonly appBar; @@ -20,6 +22,7 @@ export default class ChannelsPage { readonly deletePostModal; readonly settingsModal; + readonly postContainer; readonly postDotMenu; readonly postReminderMenu; @@ -46,21 +49,30 @@ export default class ChannelsPage { // Popovers this.emojiGifPickerPopup = new components.EmojiGifPicker(page.locator('#emojiGifPicker')); + this.scheduledDraftDropdown = new components.ScheduledDraftMenu(page.locator('#dropdown_send_post_options')); + this.scheduledDraftModal = new components.ScheduledDraftModal(page.locator('div.modal-content')); + + // Posts + this.postContainer = page.locator('div.post-message__text'); } async toBeVisible() { await this.centerView.toBeVisible(); } + async getLastPost() { + return this.postContainer.last(); + } + async goto(teamName = '', channelName = '') { let channelsUrl = '/'; if (teamName) { channelsUrl += `${teamName}`; if (channelName) { - channelsUrl += `/${channelName}`; + const prefix = channelName.startsWith('@') ? '/messages' : ''; + channelsUrl += `${prefix}/${channelName}`; } } - await this.page.goto(channelsUrl); } } diff --git a/e2e-tests/playwright/support/ui/pages/drafts.ts b/e2e-tests/playwright/support/ui/pages/drafts.ts new file mode 100644 index 0000000000..5c5732f9bd --- /dev/null +++ b/e2e-tests/playwright/support/ui/pages/drafts.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, Page} from '@playwright/test'; + +export default class DraftPage { + readonly page: Page; + + readonly badgeCountOnScheduledTab; + readonly confirmbutton; + readonly datePattern; + readonly deleteIcon; + readonly deleteIconToolTip; + readonly noscheduledDraftIcon; + readonly scheduleIcon; + readonly rescheduleIconToolTip; + readonly draftBody; + readonly scheduledDraftPageInfo; + readonly scheduledDraftPanel; + readonly scheduledDraftSendNowButton; + readonly scheduledDraftSendNowButtonToolTip; + + constructor(page: Page) { + this.page = page; + this.draftBody = page.locator('div.post__body'); + this.scheduleIcon = page.locator('#draft_icon-clock-send-outline_reschedule'); + + this.datePattern = + /(Today|Tomorrow|(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}) at \d{1,2}:\d{2} [AP]M/; + this.badgeCountOnScheduledTab = page.locator('a#draft_tabs-tab-0 div.drafts_tab_title span.MuiBadge-badge'); + // this.scheduledDraftPageInfo = page.locator('span:has-text("Send on")'); + this.scheduledDraftPageInfo = page.locator('.PanelHeader__info'); + this.scheduledDraftPanel = (messageContent: string) => + page.locator(`article.Panel:has(div.post__body:has-text("${messageContent}"))`); + this.deleteIcon = page.locator('#draft_icon-trash-can-outline_delete'); + this.deleteIconToolTip = page.locator('text=Delete scheduled post'); + this.rescheduleIconToolTip = page.locator('text=Schedule draft'); + this.noscheduledDraftIcon = page.locator('.no-results__wrapper'); + this.scheduledDraftSendNowButton = page.locator('#draft_icon-send-outline_sendNow'); + this.scheduledDraftSendNowButtonToolTip = page.locator('text=Send now'); + this.confirmbutton = this.page.locator('button.btn-primary'); + } + + async goTo(teamName: string) { + await this.page.goto(`/${teamName}/drafts`); + } + + async toBeVisible() { + await this.page.waitForLoadState('networkidle'); + await expect(this.page).toHaveURL(/.*drafts/); + } + + async assertBadgeCountOnTab(badgeCount: string) { + await this.badgeCountOnScheduledTab.isVisible(); + await expect(this.badgeCountOnScheduledTab).toHaveText(badgeCount); + } + + async assertDraftBody(draftMessage: string) { + await expect(this.draftBody).toBeVisible(); + await expect(this.draftBody).toHaveText(draftMessage); + } + + async verifyOnHoverActionItems(messageContent: string) { + await this.scheduledDraftPanel(messageContent).isVisible(); + await this.scheduledDraftPanel(messageContent).hover(); + await this.verifyDeleteIcon(); + await this.verifyScheduleIcon(messageContent); + await this.verifySendNowIcon(); + } + + async verifyDeleteIcon() { + await this.deleteIcon.isVisible(); + await this.deleteIcon.hover(); + await expect(this.deleteIconToolTip).toBeVisible(); + await expect(this.deleteIconToolTip).toHaveText('Delete scheduled post'); + } + + async verifyScheduleIcon(messageContent: string) { + await this.scheduledDraftPanel(messageContent).hover(); + await expect(this.scheduleIcon).toBeVisible(); + await this.scheduleIcon.hover(); + await expect(this.rescheduleIconToolTip).toBeVisible(); + await expect(this.rescheduleIconToolTip).toHaveText('Schedule draft'); + } + + async verifySendNowIcon() { + await this.scheduledDraftSendNowButton.isVisible(); + await this.scheduledDraftSendNowButton.hover(); + await expect(this.scheduledDraftSendNowButtonToolTip).toBeVisible(); + await expect(this.scheduledDraftSendNowButtonToolTip).toHaveText('Send now'); + } + + async getTimeStampOfMessage(messageContent: string) { + await this.scheduledDraftPanel(messageContent).scrollIntoViewIfNeeded(); + await this.scheduledDraftPanel(messageContent).isVisible(); + return this.scheduledDraftPanel(messageContent).locator(this.scheduledDraftPageInfo).innerHTML(); + } + + async openScheduleModal(messageContent: string) { + await this.scheduledDraftPanel(messageContent).scrollIntoViewIfNeeded(); + await this.scheduledDraftPanel(messageContent).isVisible(); + await this.scheduledDraftPanel(messageContent).hover(); + await this.scheduleIcon.hover(); + await expect(this.rescheduleIconToolTip).toBeVisible(); + await expect(this.rescheduleIconToolTip).toHaveText('Schedule draft'); + await this.scheduleIcon.click(); + } + + async deleteScheduledMessage(messageContent: string) { + await this.scheduledDraftPanel(messageContent).isVisible(); + await this.scheduledDraftPanel(messageContent).hover(); + await this.verifyDeleteIcon(); + await this.deleteIcon.click(); + expect(await this.confirmbutton.textContent()).toEqual('Yes, delete'); + await this.confirmbutton.click(); + } + + async sendScheduledMessage(messageContent: string) { + await this.scheduledDraftPanel(messageContent).isVisible(); + await this.scheduledDraftPanel(messageContent).hover(); + await this.verifySendNowIcon(); + await this.scheduledDraftSendNowButton.click(); + expect(await this.confirmbutton.textContent()).toEqual('Yes, send now'); + await this.confirmbutton.click(); + } +} + +export {DraftPage}; diff --git a/e2e-tests/playwright/support/ui/pages/index.ts b/e2e-tests/playwright/support/ui/pages/index.ts index 81756287bf..febb9a2d32 100644 --- a/e2e-tests/playwright/support/ui/pages/index.ts +++ b/e2e-tests/playwright/support/ui/pages/index.ts @@ -7,6 +7,8 @@ import {LoginPage} from './login'; import {ResetPasswordPage} from './reset_password'; import {SignupPage} from './signup'; import {SystemConsolePage} from './system_console'; +import {ScheduledDraftPage} from './scheduled_draft'; +import {DraftPage} from './drafts'; const pages = { ChannelsPage, @@ -14,7 +16,9 @@ const pages = { LoginPage, ResetPasswordPage, SignupPage, + ScheduledDraftPage, SystemConsolePage, + DraftPage, }; -export {pages, ChannelsPage, LandingLoginPage, LoginPage, SignupPage}; +export {pages, ChannelsPage, LandingLoginPage, LoginPage, SignupPage, ScheduledDraftPage, DraftPage}; diff --git a/e2e-tests/playwright/support/ui/pages/scheduled_draft.ts b/e2e-tests/playwright/support/ui/pages/scheduled_draft.ts new file mode 100644 index 0000000000..8416d45800 --- /dev/null +++ b/e2e-tests/playwright/support/ui/pages/scheduled_draft.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, Page} from '@playwright/test'; + +export default class ScheduledDraftPage { + readonly page: Page; + + readonly badgeCountOnScheduledTab; + readonly confirmbutton; + readonly copyIcon; + readonly copyIconToolTip; + readonly datePattern; + readonly deleteIcon; + readonly deleteIconToolTip; + readonly noscheduledDraftIcon; + readonly rescheduleIcon; + readonly rescheduleIconToolTip; + readonly scheduledDraftBody; + readonly scheduledDraftPageInfo; + readonly scheduledDraftPanel; + readonly scheduledDraftSendNowButton; + readonly scheduledDraftSendNowButtonToolTip; + readonly editIcon; + readonly editBox; + readonly editorSaveButton; + + constructor(page: Page) { + this.page = page; + + this.datePattern = + /(Today|Tomorrow|(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}) at \d{1,2}:\d{2} [AP]M/; + this.scheduledDraftBody = page.locator('div.post__body'); + this.badgeCountOnScheduledTab = page.locator('a#draft_tabs-tab-1 div.drafts_tab_title span.MuiBadge-badge'); + this.scheduledDraftPageInfo = page.locator('.PanelHeader__info'); + this.scheduledDraftPanel = (messageContent: string) => + page.locator(`article.Panel:has(div.post__body:has-text("${messageContent}"))`); + this.deleteIcon = page.locator('#draft_icon-trash-can-outline_delete'); + this.deleteIconToolTip = page.locator('text=Delete scheduled post'); + this.copyIcon = page.locator('#draft_icon-content-copy_copy_text'); + this.copyIconToolTip = page.locator('text=Copy text'); + this.rescheduleIcon = page.locator('#draft_icon-clock-send-outline_reschedule'); + this.rescheduleIconToolTip = page.locator('text=Reschedule post'); + this.noscheduledDraftIcon = page.locator('.no-results__wrapper'); + this.scheduledDraftSendNowButton = page.locator('#draft_icon-send-outline_sendNow'); + this.scheduledDraftSendNowButtonToolTip = page.locator('text=Send now'); + this.confirmbutton = this.page.locator('button.btn-primary'); + this.editIcon = page.locator('#draft_icon-pencil-outline_edit'); + this.editBox = page.locator('textarea#edit_textbox'); + this.editorSaveButton = page.locator('button.save'); + } + + async toBeVisible() { + await this.page.waitForLoadState('networkidle'); + await expect(this.page).toHaveURL(/.*scheduled_posts/); + } + + async assertBadgeCountOnTab(badgeCount: string) { + await this.badgeCountOnScheduledTab.isVisible(); + await expect(this.badgeCountOnScheduledTab).toHaveText(badgeCount); + } + + async assertscheduledDraftBody(draftMessage: string) { + await expect(this.scheduledDraftBody).toBeVisible(); + await expect(this.scheduledDraftBody).toHaveText(draftMessage); + } + + async verifyOnHoverActionItems(messageContent: string) { + await this.scheduledDraftPanel(messageContent).isVisible(); + await this.scheduledDraftPanel(messageContent).hover(); + await this.verifyDeleteIcon(); + await this.verifyCopyIcon(); + await this.verifyRescheduleIcon(); + await this.verifySendNowIcon(); + } + + async verifyDeleteIcon() { + await this.deleteIcon.isVisible(); + await this.deleteIcon.hover(); + await expect(this.deleteIconToolTip).toBeVisible(); + await expect(this.deleteIconToolTip).toHaveText('Delete scheduled post'); + } + + async verifyCopyIcon() { + await this.copyIcon.isVisible(); + await this.copyIcon.hover(); + await expect(this.copyIconToolTip).toBeVisible(); + await expect(this.copyIconToolTip).toHaveText('Copy text'); + } + + async verifyRescheduleIcon() { + await expect(this.rescheduleIcon).toBeVisible(); + await this.rescheduleIcon.hover(); + await expect(this.rescheduleIconToolTip).toBeVisible(); + await expect(this.rescheduleIconToolTip).toHaveText('Reschedule post'); + } + + async verifySendNowIcon() { + await this.scheduledDraftSendNowButton.isVisible(); + await this.scheduledDraftSendNowButton.hover(); + await expect(this.scheduledDraftSendNowButtonToolTip).toBeVisible(); + await expect(this.scheduledDraftSendNowButtonToolTip).toHaveText('Send now'); + } + + async getTimeStampOfMessage(messageContent: string) { + await this.scheduledDraftPanel(messageContent).scrollIntoViewIfNeeded(); + await this.scheduledDraftPanel(messageContent).isVisible(); + return this.scheduledDraftPanel(messageContent).locator(this.scheduledDraftPageInfo).innerHTML(); + } + + async openRescheduleModal(messageContent: string) { + await this.scheduledDraftPanel(messageContent).scrollIntoViewIfNeeded(); + await this.scheduledDraftPanel(messageContent).isVisible(); + await this.scheduledDraftPanel(messageContent).hover(); + await this.rescheduleIcon.hover(); + await expect(this.rescheduleIconToolTip).toBeVisible(); + await expect(this.rescheduleIconToolTip).toHaveText('Reschedule post'); + await this.rescheduleIcon.click(); + } + + async deleteScheduledMessage(messageContent: string) { + await this.scheduledDraftPanel(messageContent).isVisible(); + await this.scheduledDraftPanel(messageContent).hover(); + await this.verifyDeleteIcon(); + await this.deleteIcon.click(); + expect(await this.confirmbutton.textContent()).toEqual('Yes, delete'); + await this.confirmbutton.click(); + } + + async sendScheduledMessage(messageContent: string) { + await this.scheduledDraftPanel(messageContent).isVisible(); + await this.scheduledDraftPanel(messageContent).hover(); + await this.verifySendNowIcon(); + await this.scheduledDraftSendNowButton.click(); + expect(await this.confirmbutton.textContent()).toEqual('Yes, send now'); + await this.confirmbutton.click(); + } + + async goTo(teamName: string) { + await this.page.goto(`/${teamName}/scheduled_posts`); + } + + async editText(newText: string) { + await this.editIcon.click(); + await this.editBox.isVisible(); + await this.editBox.fill(newText); + await this.editorSaveButton.isVisible(); + await this.editorSaveButton.click(); + await this.editBox.isHidden(); + await this.scheduledDraftPanel(newText).isVisible(); + } + + async copyScheduledMessage(draftMessage: string) { + await this.scheduledDraftPanel(draftMessage).isVisible(); + await this.scheduledDraftPanel(draftMessage).hover(); + await this.verifyCopyIcon(); + await this.copyIcon.click(); + } +} + +export {ScheduledDraftPage}; diff --git a/e2e-tests/playwright/tests/functional/channels/scheduled_drafts/create_scheduled_draft.spec.ts b/e2e-tests/playwright/tests/functional/channels/scheduled_drafts/create_scheduled_draft.spec.ts new file mode 100644 index 0000000000..7e02299ce3 --- /dev/null +++ b/e2e-tests/playwright/tests/functional/channels/scheduled_drafts/create_scheduled_draft.spec.ts @@ -0,0 +1,456 @@ +import {expect, Page} from '@playwright/test'; +import {test} from '@e2e-support/test_fixture'; +import {ChannelsPage, ScheduledDraftPage} from '@e2e-support/ui/pages'; +import {duration, wait} from '@e2e-support/util'; + +test('MM-T5643_1 should create a scheduled message from a channel', async ({pw, pages}) => { + test.setTimeout(120000); + + const draftMessage = 'Scheduled Draft'; + // # Skip test if no license + await pw.skipIfNoLicense(); + + const {user, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(user); + const channelPage = new pages.ChannelsPage(page); + const scheduledDraftPage = new pages.ScheduledDraftPage(page); + + await setupChannelPage(channelPage, draftMessage); + await scheduleMessage(channelPage); + + await channelPage.centerView.verifyscheduledDraftChannelInfo(); + + const scheduledDraftChannelInfo = await channelPage.centerView.scheduledDraftChannelInfoMessageText.innerText(); + + await verifyscheduledDrafts(channelPage, pages, draftMessage, scheduledDraftChannelInfo); + + // # Hover and verify options + await scheduledDraftPage.verifyOnHoverActionItems(draftMessage); + + // # Go back and wait for message to arrive + await goBackToChannelAndWaitForMessageToArrive(page); + + // * Verify the message has been sent and there's no more scheduled messages + await expect(channelPage.centerView.scheduledDraftChannelInfoMessage).not.toBeVisible(); + await expect(channelPage.sidebarLeft.scheduledDraftCountonLHS).not.toBeVisible(); + await expect(await channelPage.getLastPost()).toHaveText(draftMessage); + + await verifyNoscheduledDraftsPending(channelPage, team, scheduledDraftPage, draftMessage); +}); + +test('MM-T5643_6 should create a scheduled message under a thread post ', async ({pw, pages}) => { + test.setTimeout(120000); + + const draftMessage = 'Scheduled Threaded Message'; + // # Skip test if no license + await pw.skipIfNoLicense(); + + const {user, team} = await pw.initSetup(); + + // # Log in as a 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(); + + await channelPage.centerView.postCreate.postMessage('Root Message'); + + // # Start a thread by clicking on reply menuitem from post options menu + const post = await channelPage.centerView.getLastPost(); + await replyToLastPost(post); + + const sidebarRight = channelPage.sidebarRight; + await sidebarRight.toBeVisible(); + + // # Post a message in the thread + await sidebarRight.postCreate.postMessage('Replying to a thread'); + + // # Write a message in the reply thread but don't send it + await sidebarRight.postCreate.writeMessage(draftMessage); + await expect(sidebarRight.postCreate.input).toHaveText(draftMessage); + + await sidebarRight.postCreate.scheduleDraftMessageButton.isVisible(); + await sidebarRight.postCreate.scheduleDraftMessageButton.click(); + + await scheduleMessage(channelPage); + + await sidebarRight.scheduledDraftChannelInfo.isVisible(); + const messageLocator = sidebarRight.scheduledDraftChannelInfoMessage.first(); + await expect(messageLocator).toContainText('Message scheduled for'); + + // Save the time displayed in the thread + const scheduledDraftThreadedPanelInfo = await sidebarRight.scheduledDraftChannelInfo.innerText(); + const scheduledDraftPage = new pages.ScheduledDraftPage(page); + + await channelPage.sidebarRight.clickOnSeeAllscheduledDrafts(); + const scheduledDraftPageInfo = await scheduledDraftPage.scheduledDraftPageInfo.innerHTML(); + await channelPage.sidebarLeft.assertscheduledDraftCountLHS('1'); + + await scheduledDraftPage.toBeVisible(); + await scheduledDraftPage.assertBadgeCountOnTab('1'); + + await scheduledDraftPage.assertscheduledDraftBody(draftMessage); + + await compareMessageTimestamps(scheduledDraftThreadedPanelInfo, scheduledDraftPageInfo, scheduledDraftPage); + + // # Hover and verify options + await scheduledDraftPage.verifyOnHoverActionItems(draftMessage); + + await goBackToChannelAndWaitForMessageToArrive(page); + + await replyToLastPost(post); + + // * Verify the message has been sent and there's no more scheduled messages + await expect(channelPage.sidebarRight.scheduledDraftChannelInfoMessage).not.toBeVisible(); + await expect(channelPage.sidebarLeft.scheduledDraftCountonLHS).not.toBeVisible(); + + const lastPost = channelPage.sidebarRight.rhsPostBody.last(); + await expect(lastPost).toHaveText(draftMessage); + await expect(scheduledDraftPage.scheduledDraftPanel(draftMessage)).not.toBeVisible(); + + await verifyNoscheduledDraftsPending(channelPage, team, scheduledDraftPage, draftMessage); +}); + +test('MM-T5644 should rechedule a scheduled message', async ({pw, pages}) => { + const draftMessage = 'Scheduled Draft'; + await pw.skipIfNoLicense(); + + const {user} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(user); + const channelPage = new pages.ChannelsPage(page); + const scheduledDraftPage = new pages.ScheduledDraftPage(page); + + await setupChannelPage(channelPage, draftMessage); + await scheduleMessage(channelPage); + + // * Verify the Initial Date and time of scheduled Draft + await channelPage.centerView.verifyscheduledDraftChannelInfo(); + const scheduledDraftChannelInfo = await channelPage.centerView.scheduledDraftChannelInfoMessageText.innerText(); + await verifyscheduledDrafts(channelPage, pages, draftMessage, scheduledDraftChannelInfo); + + await scheduledDraftPage.openRescheduleModal(draftMessage); + + // # Reschedule it to 2 days from today + await channelPage.scheduledDraftModal.selectDay(2); + await channelPage.scheduledDraftModal.confirm(); + + // # Note the new Scheduled time + const scheduledDraftPageInfo = await scheduledDraftPage.getTimeStampOfMessage(draftMessage); + + // # Go to Channel + await channelPage.goto(); + + // * Verify the New Time reflecting in the channel + const rescheduledDraftChannelInfo = await channelPage.centerView.scheduledDraftChannelInfoMessageText.innerText(); + await compareMessageTimestamps(rescheduledDraftChannelInfo, scheduledDraftPageInfo, scheduledDraftPage); +}); + +test('MM-T5645 should delete a scheduled message', async ({pw, pages}) => { + const draftMessage = 'Scheduled Draft'; + await pw.skipIfNoLicense(); + + const {user} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(user); + const channelPage = new pages.ChannelsPage(page); + const scheduledDraftPage = new pages.ScheduledDraftPage(page); + + await setupChannelPage(channelPage, draftMessage); + await scheduleMessage(channelPage); + const scheduledDraftChannelInfo = await channelPage.centerView.scheduledDraftChannelInfoMessageText.innerText(); + + await verifyscheduledDrafts(channelPage, pages, draftMessage, scheduledDraftChannelInfo); + + await scheduledDraftPage.deleteScheduledMessage(draftMessage); + + await expect(scheduledDraftPage.scheduledDraftPanel(draftMessage)).not.toBeVisible(); + await expect(scheduledDraftPage.noscheduledDraftIcon).toBeVisible(); +}); + +test('MM-T5643_9 should send a scheduled message immediately', async ({pw, pages}) => { + const draftMessage = 'Scheduled Draft'; + await pw.skipIfNoLicense(); + + const {user} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(user); + const channelPage = new pages.ChannelsPage(page); + const scheduledDraftPage = new pages.ScheduledDraftPage(page); + + await setupChannelPage(channelPage, draftMessage); + await scheduleMessage(channelPage); + const scheduledDraftChannelInfo = await channelPage.centerView.scheduledDraftChannelInfoMessageText.innerText(); + + await verifyscheduledDrafts(channelPage, pages, draftMessage, scheduledDraftChannelInfo); + + await scheduledDraftPage.sendScheduledMessage(draftMessage); + + await expect(scheduledDraftPage.scheduledDraftPanel(draftMessage)).not.toBeVisible(); + + // Verify message has arrived + await expect(channelPage.centerView.scheduledDraftChannelInfoMessage).not.toBeVisible(); + await expect(channelPage.sidebarLeft.scheduledDraftCountonLHS).not.toBeVisible(); + await expect(await channelPage.getLastPost()).toHaveText(draftMessage); +}); + +test('MM-T5643_3 should create a scheduled message from a DM', async ({pw, pages}) => { + test.setTimeout(120000); + + const draftMessage = 'Scheduled Draft'; + // # Skip test if no license + await pw.skipIfNoLicense(); + + const {user, team} = await pw.initSetup(); + const {user: user2} = await pw.initSetup(); + + const {page} = await pw.testBrowser.login(user); + const channelPage = new pages.ChannelsPage(page); + const scheduledDraftPage = new pages.ScheduledDraftPage(page); + + await setupChannelPage(channelPage, draftMessage, team.name, `@${user2.username}`); + await scheduleMessage(channelPage); + + await channelPage.centerView.verifyscheduledDraftChannelInfo(); + + const scheduledDraftChannelInfo = await channelPage.centerView.scheduledDraftChannelInfoMessageText.innerText(); + + await verifyscheduledDrafts(channelPage, pages, draftMessage, scheduledDraftChannelInfo); + + // # Hover and verify options + await scheduledDraftPage.verifyOnHoverActionItems(draftMessage); + + // # Go back and wait for message to arrive + await goBackToChannelAndWaitForMessageToArrive(page); + + // * Verify the message has been sent and there's no more scheduled messages + await expect(channelPage.centerView.scheduledDraftChannelInfoMessage).not.toBeVisible(); + await expect(channelPage.sidebarLeft.scheduledDraftCountonLHS).not.toBeVisible(); + await expect(await channelPage.getLastPost()).toHaveText(draftMessage); + + await verifyNoscheduledDraftsPending(channelPage, team, scheduledDraftPage, draftMessage); +}); + +test('MM-T5648 should create a draft and then schedule it', async ({pw, pages}) => { + test.setTimeout(120000); + + const draftMessage = 'Draft to be Scheduled'; + await pw.skipIfNoLicense(); + + const {user, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(user); + const channelPage = new pages.ChannelsPage(page); + + // await setupChannelPage(channelPage, draftMessage); + await channelPage.goto(); + await channelPage.toBeVisible(); + await channelPage.centerView.postCreate.writeMessage(draftMessage); + + // go to drafts page + const draftsPage = new pages.DraftPage(page); + await draftsPage.goTo(team.name); + await draftsPage.toBeVisible(); + await draftsPage.assertBadgeCountOnTab('1'); + await draftsPage.assertDraftBody(draftMessage); + await draftsPage.verifyScheduleIcon(draftMessage); + await draftsPage.openScheduleModal(draftMessage); + + // # Reschedule it to 2 days from today + await channelPage.scheduledDraftModal.selectDay(2); + await channelPage.scheduledDraftModal.confirm(); + + const scheduledDraftPage = new pages.ScheduledDraftPage(page); + await scheduledDraftPage.goTo(team.name); + await scheduledDraftPage.toBeVisible(); + await scheduledDraftPage.assertBadgeCountOnTab('1'); + await scheduledDraftPage.assertscheduledDraftBody(draftMessage); +}); + +test('MM-T5644 should edit scheduled message', async ({pw, pages}) => { + test.setTimeout(120000); + + const draftMessage = 'Scheduled Draft'; + // # Skip test if no license + await pw.skipIfNoLicense(); + + const {user, team} = await pw.initSetup(); + const {page} = await pw.testBrowser.login(user); + const channelPage = new pages.ChannelsPage(page); + const scheduledDraftPage = new pages.ScheduledDraftPage(page); + + await setupChannelPage(channelPage, draftMessage); + await scheduleMessage(channelPage); + + await channelPage.centerView.verifyscheduledDraftChannelInfo(); + + const scheduledDraftChannelInfo = await channelPage.centerView.scheduledDraftChannelInfoMessageText.innerText(); + + await verifyscheduledDrafts(channelPage, pages, draftMessage, scheduledDraftChannelInfo); + + // # Hover and verify options + await scheduledDraftPage.verifyOnHoverActionItems(draftMessage); + + const updatedText = 'updated text'; + await scheduledDraftPage.editText(updatedText); + + // # Go back and wait for message to arrive + await goBackToChannelAndWaitForMessageToArrive(page); + + // * Verify the message has been sent and there's no more scheduled messages + await expect(channelPage.centerView.scheduledDraftChannelInfoMessage).not.toBeVisible(); + await expect(channelPage.sidebarLeft.scheduledDraftCountonLHS).not.toBeVisible(); + await expect(await channelPage.getLastPost()).toHaveText(draftMessage); + + await verifyNoscheduledDraftsPending(channelPage, team, scheduledDraftPage, draftMessage); +}); + +test('MM-T5650 should copy scheduled message', async ({pw, pages, browserName}) => { + test.setTimeout(120000); + + // Skip this test in Firefox clipboard permissions are not supported + test.skip(browserName === 'firefox', 'Test not supported in Firefox'); + + const draftMessage = 'Scheduled Draft'; + // # Skip test if no license + await pw.skipIfNoLicense(); + + const {user} = await pw.initSetup(); + const {page, context} = await pw.testBrowser.login(user); + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + + const channelPage = new pages.ChannelsPage(page); + const scheduledDraftPage = new pages.ScheduledDraftPage(page); + + await setupChannelPage(channelPage, draftMessage); + await scheduleMessage(channelPage); + + await channelPage.centerView.verifyscheduledDraftChannelInfo(); + + const scheduledDraftChannelInfo = await channelPage.centerView.scheduledDraftChannelInfoMessageText.innerText(); + + await verifyscheduledDrafts(channelPage, pages, draftMessage, scheduledDraftChannelInfo); + + await scheduledDraftPage.copyScheduledMessage(draftMessage); + + await page.goBack(); + + await channelPage.centerView.postCreate.input.focus(); + + await page.keyboard.down('Meta'); + await page.keyboard.press('V'); + await page.keyboard.up('Meta'); + + // * Assert the message typed is same as the copied message + await expect(channelPage.centerView.postCreate.input).toHaveText(draftMessage); +}); + +async function verifyNoscheduledDraftsPending( + channelPage: ChannelsPage, + team: any, + scheduledDraftPage: ScheduledDraftPage, + draftMessage: string, +): Promise { + await channelPage.goto(team.name, 'scheduled_posts'); + await expect(scheduledDraftPage.scheduledDraftPanel(draftMessage)).not.toBeVisible(); + await expect(scheduledDraftPage.noscheduledDraftIcon).toBeVisible(); +} + +async function goBackToChannelAndWaitForMessageToArrive(page: Page): Promise { + await page.goBack(); + await wait(duration.half_min); + await page.reload(); +} + +async function replyToLastPost(post: any): Promise { + await post.hover(); + await post.postMenu.toBeVisible(); + await post.postMenu.reply(); +} + +async function setupChannelPage( + channelPage: ChannelsPage, + draftMessage: string, + teamName?: string, + channelName?: string, +): Promise { + await channelPage.goto(teamName, channelName); + await channelPage.toBeVisible(); + + await channelPage.centerView.postCreate.writeMessage(draftMessage); + await channelPage.centerView.postCreate.clickOnScheduleDraftDropdownButton(); +} + +/** + * Schedules a draft message by selecting a custom time and confirming. + */ +async function scheduleMessage(pageObject: ChannelsPage): Promise { + await pageObject.scheduledDraftDropdown.toBeVisible(); + await pageObject.scheduledDraftDropdown.selectCustomTime(); + + await pageObject.scheduledDraftModal.toBeVisible(); + await pageObject.scheduledDraftModal.selectDay(); + await pageObject.scheduledDraftModal.selectTime(); + await pageObject.scheduledDraftModal.confirm(); +} + +/** + * Extracts and verifies the scheduled message on the scheduled page and in the channel. + */ +async function verifyscheduledDrafts( + channelPage: ChannelsPage, + pages: any, + draftMessage: string, + scheduledDraftChannelInfo: string, +): Promise { + const scheduledDraftPage = new pages.ScheduledDraftPage(channelPage.page); + + await verifyscheduledDraftCount(channelPage, '1'); + await scheduledDraftPage.toBeVisible(); + await scheduledDraftPage.assertBadgeCountOnTab('1'); + await scheduledDraftPage.assertscheduledDraftBody(draftMessage); + + const scheduledDraftPageInfo = await scheduledDraftPage.getTimeStampOfMessage(draftMessage); + await compareMessageTimestamps(scheduledDraftChannelInfo, scheduledDraftPageInfo, scheduledDraftPage); +} + +/** + * Verifies the scheduled message count on the sidebar. + */ +async function verifyscheduledDraftCount(page: ChannelsPage, expectedCount: string): Promise { + await page.centerView.clickOnSeeAllscheduledDrafts(); + await page.sidebarLeft.assertscheduledDraftCountLHS(expectedCount); +} + +/** + * Compares the time in the channel and the scheduled page to ensure consistency. + */ +async function compareMessageTimestamps( + timeInChannel: string, + scheduledDraftPageInfo: string, + scheduledDraftPage: ScheduledDraftPage, +): Promise { + // Extract time from channel using the same date pattern + const matchedTimeInChannel = timeInChannel.match(scheduledDraftPage.datePattern); + const timeInSchedulePage = extractTimeFromHtml(scheduledDraftPageInfo, scheduledDraftPage); + + if (!matchedTimeInChannel || !timeInSchedulePage) { + throw new Error('Could not extract date and time from one or both elements.'); + } + + const firstElementTime = matchedTimeInChannel[0]; + const secondElementTime = timeInSchedulePage[0]; + + // Compare extracted times + expect(firstElementTime).toBe(secondElementTime); +} + +/** + * Removes HTML tags from the scheduled message content and extracts the time pattern. + */ +function extractTimeFromHtml(htmlContent: string, scheduledDraftPage: ScheduledDraftPage): RegExpMatchArray | null { + // Remove all HTML tags and match the expected time pattern using the datePattern from scheduledDraftPage + const cleanedText = htmlContent.replace(/<\/?[^>]+(>|$)/g, ''); + + // Use the datePattern to extract the exact match for time + const matchedTime = cleanedText.match(scheduledDraftPage.datePattern); + return matchedTime; +} diff --git a/server/channels/app/scheduled_post_test.go b/server/channels/app/scheduled_post_test.go index 84c3da3768..c1a1404f79 100644 --- a/server/channels/app/scheduled_post_test.go +++ b/server/channels/app/scheduled_post_test.go @@ -497,6 +497,7 @@ func TestUpdateScheduledPost(t *testing.T) { }, ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost, user1ConnID) require.Nil(t, appErr) require.NotNil(t, createdScheduledPost) @@ -556,7 +557,6 @@ func TestUpdateScheduledPost(t *testing.T) { newScheduledAtTime := model.GetMillis() + 9999999 createdScheduledPost.ScheduledAt = newScheduledAtTime createdScheduledPost.Message = "Updated Message!!!" - updatedScheduledPost, appErr := th.App.UpdateScheduledPost(th.Context, th.BasicUser2.Id, createdScheduledPost, user1ConnID) require.NotNil(t, appErr) require.Equal(t, http.StatusForbidden, appErr.StatusCode) diff --git a/webapp/channels/src/components/custom_status/date_time_input.tsx b/webapp/channels/src/components/custom_status/date_time_input.tsx index 962974170c..7467cc516e 100644 --- a/webapp/channels/src/components/custom_status/date_time_input.tsx +++ b/webapp/channels/src/components/custom_status/date_time_input.tsx @@ -23,8 +23,8 @@ import Input from 'components/widgets/inputs/input/input'; import Menu from 'components/widgets/menu/menu'; import MenuWrapper from 'components/widgets/menu/menu_wrapper'; -import Constants, {A11yCustomEventTypes} from 'utils/constants'; import type {A11yFocusEventDetail} from 'utils/constants'; +import Constants, {A11yCustomEventTypes} from 'utils/constants'; import {relativeFormatDate} from 'utils/datetime'; import {isKeyPressed} from 'utils/keyboard'; import {getCurrentMomentForTimezone} from 'utils/timezone'; diff --git a/webapp/channels/src/components/edit_post/index.ts b/webapp/channels/src/components/edit_post/index.ts index 55ba6fe893..30c6bdb7a8 100644 --- a/webapp/channels/src/components/edit_post/index.ts +++ b/webapp/channels/src/components/edit_post/index.ts @@ -63,8 +63,10 @@ function mapStateToProps(state: GlobalState, props: Props) { const channel = getChannel(state, channelId); const useChannelMentions = haveIChannelPermission(state, teamId, channelId, Permissions.USE_CHANNEL_MENTIONS); + const canEdit = haveIChannelPermission(state, teamId, channelId, editPermission); + return { - canEditPost: haveIChannelPermission(state, teamId, channelId, editPermission), + canEditPost: canEdit, canDeletePost: haveIChannelPermission(state, teamId, channelId, deletePermission), codeBlockOnCtrlEnter: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'code_block_ctrl_enter', true), ctrlSend: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),