diff --git a/e2e-tests/cypress/tests/support/chai.d.ts b/e2e-tests/cypress/tests/support/chai.d.ts new file mode 100644 index 0000000000..c9f939fe3f --- /dev/null +++ b/e2e-tests/cypress/tests/support/chai.d.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/// + +declare namespace Cypress { + interface Chainer { + (chainer: 'be.focusVisible', {exactStyles}: {exactStyles?: boolean}): Chainable; + + (chainer: 'not.be.focusVisible', {exactStyles}: {exactStyles?: boolean}): Chainable; + } +} diff --git a/e2e-tests/cypress/tests/support/chai.ts b/e2e-tests/cypress/tests/support/chai.ts new file mode 100644 index 0000000000..6c63a30b25 --- /dev/null +++ b/e2e-tests/cypress/tests/support/chai.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +chai.use((chai: Chai.ChaiStatic) => { + function assertIsFoo({exactStyles = true} = {}) { + // eslint-disable-next-line no-underscore-dangle + const obj = this._obj as JQuery; + + this.assert( + obj.hasClass('a11y--active'), + 'expected #{this} to have a11y--active class', + 'expected #{this} to not have a11y--active class', + obj, + ); + + // These should match the styles set on :focus-visible in sass/utils/_modifiers.scss + this.assert( + exactStyles ? obj.css('box-shadow').includes('0px 0px 1px 3px') : Boolean(obj.css('box-shadow')), + 'expected #{this} to have focused element style (box-shadow)', + 'expected #{this} to not have focused element style (box-shadow)', + obj.css('box-shadow'), + ); + + this.assert( + exactStyles ? obj.css('border-radius') === '4px' : Boolean(obj.css('border-radius')), + 'expected #{this} to have focused element style (border-radius)', + 'expected #{this} to not have focused element style (border-radius)', + obj.css('border-radius'), + ); + + return this; + } + /* eslint-enable no-underscore-dangle */ + + chai.Assertion.addMethod('a11yVisible', assertIsFoo); +}); diff --git a/e2e-tests/playwright/lib/src/browser_context.ts b/e2e-tests/playwright/lib/src/browser_context.ts index 2c1e9fddb2..450bdda201 100644 --- a/e2e-tests/playwright/lib/src/browser_context.ts +++ b/e2e-tests/playwright/lib/src/browser_context.ts @@ -34,10 +34,11 @@ export class TestBrowser { const systemConsolePage = new pages.SystemConsolePage(page); const scheduledPostsPage = new pages.ScheduledPostsPage(page); const draftsPage = new pages.DraftsPage(page); + const threadsPage = new pages.ThreadsPage(page); this.context = context; - return {context, page, channelsPage, systemConsolePage, scheduledPostsPage, draftsPage}; + return {context, page, channelsPage, systemConsolePage, scheduledPostsPage, draftsPage, threadsPage}; } async close() { diff --git a/e2e-tests/playwright/lib/src/ui/pages/index.ts b/e2e-tests/playwright/lib/src/ui/pages/index.ts index a36a892dfa..624199b77c 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/index.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/index.ts @@ -9,6 +9,7 @@ import SignupPage from './signup'; import SystemConsolePage from './system_console'; import ScheduledPostsPage from './scheduled_posts'; import DraftsPage from './drafts'; +import ThreadsPage from './threads'; const pages = { ChannelsPage, @@ -19,6 +20,7 @@ const pages = { ScheduledPostsPage, SystemConsolePage, DraftsPage, + ThreadsPage, }; export { diff --git a/e2e-tests/playwright/lib/src/ui/pages/threads.ts b/e2e-tests/playwright/lib/src/ui/pages/threads.ts new file mode 100644 index 0000000000..5736ea4728 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/pages/threads.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Page, expect} from '@playwright/test'; + +import {ChannelsPost} from '@/ui/components'; + +export default class ThreadsPage { + readonly page: Page; + + readonly threadsList; + + readonly noThreadSelected; + + constructor(page: Page) { + this.page = page; + + this.threadsList = page.locator('#threads-list'); + + this.noThreadSelected = page.locator('.no-results__title', { + hasText: /Looks like you’re all caught up|Catch up on your threads/, + }); + } + + async goto(teamName: string) { + await this.page.goto(`/${teamName}/threads`); + } + + async toBeVisible() { + await expect(this.threadsList).toBeVisible(); + } + + async toHaveThreadSelected() { + await expect(this.noThreadSelected).not.toBeAttached(); + } + + async toNotHaveThreadSelected() { + await expect(this.noThreadSelected).toBeVisible(); + } + + async getLastPost() { + const lastPost = this.page.getByTestId('rhsPostView').last(); + await lastPost.waitFor(); + return new ChannelsPost(lastPost); + } +} diff --git a/e2e-tests/playwright/specs/functional/channels/threads/threads_list.spec.ts b/e2e-tests/playwright/specs/functional/channels/threads/threads_list.spec.ts new file mode 100644 index 0000000000..70cc1cb261 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/threads/threads_list.spec.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test} from '@mattermost/playwright-lib'; + +test('Should be able to change threads with arrow keys', async ({pw}, testInfo) => { + test.skip(testInfo.project.name === 'ipad'); + + const {team, user} = await pw.initSetup(); + + const {channelsPage, page, threadsPage} = await pw.testBrowser.login(user); + + await channelsPage.goto(); + await channelsPage.toBeVisible(); + + // # Start some threads, and leave a draft in one of them + await channelsPage.centerView.postCreate.postMessage('aaa'); + await (await channelsPage.getLastPost()).openAThread(); + await channelsPage.sidebarRight.postMessage('aaa reply'); + + await channelsPage.centerView.postCreate.postMessage('bbb'); + await (await channelsPage.getLastPost()).openAThread(); + await channelsPage.sidebarRight.postMessage('bbb reply'); + await channelsPage.sidebarRight.postCreate.writeMessage('bbb second reply'); + + await channelsPage.centerView.postCreate.postMessage('ccc'); + await (await channelsPage.getLastPost()).openAThread(); + await channelsPage.sidebarRight.postMessage('ccc reply'); + + // * Ensure that there's a draft + await channelsPage.sidebarLeft.draftsVisible(); + + // # Switch to the threads list + await threadsPage.goto(team.name); + await threadsPage.toBeVisible(); + + // * Ensure no thread starts selected + await threadsPage.toNotHaveThreadSelected(); + + // # Press the down arrow to select a thread + await page.keyboard.press('ArrowDown'); + + // * Ensure the latest thread was selected + await threadsPage.toHaveThreadSelected(); + (await threadsPage.getLastPost()).toContainText('ccc reply'); + + // # Press the down arrow again + await page.keyboard.press('ArrowDown'); + + // * Ensure the latest thread was selected + await threadsPage.toHaveThreadSelected(); + (await threadsPage.getLastPost()).toContainText('bbb reply'); + + await threadsPage.threadsList.focus(); + + // # Press the down arrow again + await page.keyboard.press('ArrowDown'); + + // * Ensure the latest thread was selected + await threadsPage.toHaveThreadSelected(); + (await threadsPage.getLastPost()).toContainText('aaa reply'); + + // # Press the up arrow + await page.keyboard.press('ArrowUp'); + + // * Ensure the latest thread was selected + await threadsPage.toHaveThreadSelected(); + (await threadsPage.getLastPost()).toContainText('bbb reply'); + + // # Press the up arrow + await page.keyboard.press('ArrowUp'); + + // * Ensure the latest thread was selected + await threadsPage.toHaveThreadSelected(); + (await threadsPage.getLastPost()).toContainText('ccc reply'); +}); diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx index 4bcfdd51d4..87d35e3abf 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx @@ -523,7 +523,7 @@ const AdvancedTextEditor = ({ // Update the caret position in the input box when changed by a side effect useEffect(() => { const textbox: HTMLInputElement | HTMLTextAreaElement | undefined = textboxRef.current?.getInputBox(); - if (textbox && textbox.selectionStart !== caretPosition) { + if (textbox && textbox === document.activeElement && textbox.selectionStart !== caretPosition) { Utils.setCaretPosition(textbox, caretPosition); } }, [caretPosition]);