diff --git a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_keyboard_usability_spec.js b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_keyboard_usability_spec.js index 9aa70288e9..928b2e32ee 100644 --- a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_keyboard_usability_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_keyboard_usability_spec.js @@ -52,7 +52,8 @@ describe('Verify Accessibility keyboard usability across different regions in th postMessages(testChannel, otherUser, count); // # Search for a term - cy.get('#searchBox').typeWithForce('hello').typeWithForce('{enter}'); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').typeWithForce('hello').typeWithForce('{enter}'); // # Change the focus to search results cy.get('#searchContainer').within(() => { diff --git a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_nav_diff_regions_spec.js b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_nav_diff_regions_spec.js index cfc82f0824..431ba75fd3 100644 --- a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_nav_diff_regions_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_nav_diff_regions_spec.js @@ -125,7 +125,8 @@ describe('Verify Quick Navigation support across different regions in the app', it('MM-T1460_8 Verify Navigation Support in Search Results', () => { // # Search for some text - cy.get('#searchBox').should('be.visible').type('hello {enter}'); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').should('be.visible').type('hello {enter}'); // # Change the focus to search results cy.get('#searchContainer').within(() => { diff --git a/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_reaction_spec.ts b/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_reaction_spec.ts index e778940716..00a705d165 100644 --- a/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_reaction_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_reaction_spec.ts @@ -64,7 +64,8 @@ describe('Archived channels', () => { // # Search for "Test archive reaction" cy.get('body').type('{esc}'); - cy.get('#searchBox').should('be.visible').type(messageText).type('{enter}'); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').should('be.visible').type(messageText).type('{enter}'); // # Click on post dot menu so we can check for reaction icon cy.clickPostDotMenu(postId, 'SEARCH'); diff --git a/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_search_spec.ts b/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_search_spec.ts index 8db985d456..17df3e53bd 100644 --- a/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_search_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_search_spec.ts @@ -60,7 +60,8 @@ describe('archive tests while preventing viewing archived channels', () => { cy.postMessage(getRandomId()); // # Search for the post from step 1') - cy.get('#searchBox').click().clear().type(`${messageText}{enter}`); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').clear().type(`${messageText}{enter}`); // * Post is returned by search, since it's not archived anymore cy.get('#searchContainer').should('be.visible'); @@ -94,7 +95,8 @@ describe('archive tests while preventing viewing archived channels', () => { cy.uiArchiveChannel(); // # Archive dialogue message reads "This will archive the channel from the team and make its contents inaccessible for all users" (Mobile dialogue makes no mention of the data will be accessible) - cy.get('#searchBox').click().clear().type(`${testArchivedMessage}{enter}`); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').clear().type(`${testArchivedMessage}{enter}`); // * Post is not returned by search cy.get('#searchContainer').should('be.visible'); @@ -109,7 +111,8 @@ describe('archive tests while preventing viewing archived channels', () => { cy.postMessage(messageText); // # Search for the string of text from step 1 - cy.get('#searchBox').click().clear().type(`${messageText}{enter}`); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').clear().type(`${messageText}{enter}`); // * Post is returned by search, since it's not archived anymore cy.get('#searchContainer').should('be.visible'); @@ -127,7 +130,8 @@ describe('archive tests while preventing viewing archived channels', () => { it('MM-T1710 archived channels are not listed on the "in:" autocomplete', () => { // # Archive a channel and make a mental note of the channel name // # Type "in:" and note the list of channels that appear - cy.get('#searchBox').click().clear().type(`in:${testChannel.name}`); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').clear().type(`in:${testChannel.name}`); cy.findByTestId(testChannel.name).should('not.exist'); }); }); diff --git a/e2e-tests/cypress/tests/integration/channels/archived_channel/archived_channel_spec.ts b/e2e-tests/cypress/tests/integration/channels/archived_channel/archived_channel_spec.ts index 09fbaf74ce..65cfa07151 100644 --- a/e2e-tests/cypress/tests/integration/channels/archived_channel/archived_channel_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/archived_channel/archived_channel_spec.ts @@ -41,10 +41,11 @@ describe('Leave an archived channel', () => { cy.get('#channelInfoModalLabel span.icon__archive').should('not.exist'); // # Search for a post in an archived channel - cy.get('#searchBox').click().clear().type(`${testArchivedMessage}{enter}`); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').clear().type(`${testArchivedMessage}{enter}`); // # Open the archived channel by selecting Jump from search results and then selecting the link to move to the most recent posts in the channel - cy.get('#searchContainer').should('be.visible'); + cy.uiGetSearchContainer().should('be.visible'); cy.get('a.search-item__jump').first().click(); @@ -111,10 +112,11 @@ describe('Leave an archived channel', () => { const messageList = Array.from({length: 40}, (_, i) => `${i}. any - ${getRandomId()}`); createArchivedChannel({prefix: 'archived-search-for'}, messageList).then(({name}) => { // # Locate the post in a search - cy.get('#searchBox').click().clear().type(`${messageList[1]}{enter}`); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').clear().type(`${messageList[1]}{enter}`); // # Click jump to open an archive post in permalink view - cy.get('#searchContainer').should('be.visible'); + cy.uiGetSearchContainer().should('be.visible'); cy.get('a.search-item__jump').first().click(); // * Archived channel is opened in permalink view diff --git a/e2e-tests/cypress/tests/integration/channels/archived_channel/archived_leave_channel_spec.ts b/e2e-tests/cypress/tests/integration/channels/archived_channel/archived_leave_channel_spec.ts index c56bb79fed..8531fa5a15 100644 --- a/e2e-tests/cypress/tests/integration/channels/archived_channel/archived_leave_channel_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/archived_channel/archived_leave_channel_spec.ts @@ -96,7 +96,8 @@ describe('Leave an archived channel', () => { cy.visit(previousChannel); // # Search for content from an archived channel - cy.get('#searchBox').click().clear().type(`${messageD}{enter}`); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').clear().type(`${messageD}{enter}`); // # Open the channel from search results cy.get('#searchContainer').should('be.visible'); @@ -107,7 +108,8 @@ describe('Leave an archived channel', () => { cy.url().should('contain', `${testTeam.name}/channels/${archivedChannelD.name}`); // # Search for content from a different archived channel - cy.get('#searchBox').click().clear().type(`${messageC}{enter}`); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').clear().type(`${messageC}{enter}`); // # Open the channel from search result by clicking Jump cy.get('#searchContainer').should('be.visible').findByText('Jump').click().wait(TIMEOUTS.ONE_SEC); diff --git a/e2e-tests/cypress/tests/integration/channels/channel/open_rhs_coming_from_system_console_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel/open_rhs_coming_from_system_console_spec.ts index 8683b4f197..a88eb69562 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/open_rhs_coming_from_system_console_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel/open_rhs_coming_from_system_console_spec.ts @@ -133,7 +133,8 @@ describe('Channel RHS', () => { cy.postMessage(MESSAGES.SMALL); // # Enter the search terms and hit enter to start the search - cy.get('#searchBox').clear().type(MESSAGES.TINY).type('{enter}'); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').clear().type(MESSAGES.TINY).type('{enter}'); // * Verify that the search results is opened in RHS verifyRHSisOpenAndHasTitle('Search Results'); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts index d4474c3f14..ef7ee0bf6b 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts @@ -78,15 +78,18 @@ describe('Verify Accessibility Support in different input fields', () => { }); } + cy.uiGetSearchContainer().click(); + // * Verify Accessibility support in search input - cy.get('#searchBox').should('have.attr', 'aria-describedby', 'searchbar-help-popup').and('have.attr', 'aria-label', 'Search').focus(); - cy.get('#searchbar-help-popup').should('be.visible').and('have.attr', 'role', 'tooltip'); + cy.uiGetSearchBox().should('have.attr', 'aria-describedby', 'searchHints').and('have.attr', 'aria-label', 'Search'); + cy.uiGetSearchBox().find('input').focus(); + cy.get('#searchHints').should('be.visible'); // # Ensure User list is cached once in UI - cy.get('#searchBox').type('from:').wait(TIMEOUTS.FIVE_SEC); + cy.uiGetSearchBox().find('input').type('from:').wait(TIMEOUTS.FIVE_SEC); // # Trigger the user autocomplete again - cy.get('#searchBox').clear().type('from:').wait(TIMEOUTS.FIVE_SEC).type('{downarrow}{downarrow}'); + cy.uiGetSearchBox().find('input').clear().type('from:').wait(TIMEOUTS.FIVE_SEC).type('{downarrow}{downarrow}'); // * Verify Accessibility Support in search autocomplete verifySearchAutocomplete(2); @@ -100,10 +103,10 @@ describe('Verify Accessibility Support in different input fields', () => { verifySearchAutocomplete(3); // # Type the in: filter and ensure channel list is cached once - cy.get('#searchBox').clear().type('in:').wait(TIMEOUTS.FIVE_SEC); + cy.uiGetSearchBox().find('input').clear().type('in:').wait(TIMEOUTS.FIVE_SEC); // # Trigger the channel autocomplete again - cy.get('#searchBox').clear().type('in:').wait(TIMEOUTS.FIVE_SEC).type('{downarrow}{downarrow}'); + cy.uiGetSearchBox().find('input').clear().type('in:').wait(TIMEOUTS.FIVE_SEC).type('{downarrow}{downarrow}'); // * Verify Accessibility Support in search autocomplete verifySearchAutocomplete(2, 'channel'); @@ -287,7 +290,7 @@ function getUserMentionAriaLabel(displayName) { } function verifySearchAutocomplete(index, type = 'user') { - cy.get('#search-autocomplete__popover').find('.suggestion-list__item').eq(index).should('be.visible').and('have.class', 'suggestion--selected').within((el) => { + cy.uiGetSearchBox().find('.suggestion-list__item').eq(index).should('be.visible').and('have.class', 'suggestion--selected').within((el) => { if (type === 'user') { cy.get('.suggestion-list__ellipsis').invoke('text').then((text) => { const usernameLength = 12; diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_add_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_add_spec.ts index 37e1f89141..db5c0ac768 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_add_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_add_spec.ts @@ -78,10 +78,10 @@ describe('Guest Account - Guest User Experience', () => { cy.sendDirectMessageToUser(guestUser, 'hello'); // # Search for the Guest User - cy.get('#searchBox').wait(TIMEOUTS.FIVE_SEC).type(`in:${guestUser.username}`); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').wait(TIMEOUTS.FIVE_SEC).type(`in:${guestUser.username}`); // * Verify Guest Badge is not displayed at Search auto-complete - cy.get('#search-autocomplete__popover').should('be.visible'); cy.contains('.suggestion-list__item', guestUser.username).should('be.visible').within(($el) => { cy.wrap($el).find('.Tag').should('not.exist'); }); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_ui_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_ui_spec.ts index fffa302100..7da50e59dc 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_ui_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_ui_spec.ts @@ -212,10 +212,10 @@ describe('Verify Guest User Identification in different screens', () => { it('Verify Guest Badge not displayed in Search Autocomplete', () => { // # Search for the Guest User - cy.get('#searchBox').type('from:'); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').type('from:'); // * Verify Guest Badge is not displayed at Search auto-complete - cy.get('#search-autocomplete__popover').should('be.visible'); cy.contains('.suggestion-list__item', guestUser.username).scrollIntoView().should('be.visible').within(($el) => { cy.wrap($el).find('.Tag').should('not.exist'); }); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/integrations/incoming_webhook_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/integrations/incoming_webhook_spec.ts index d287dc178f..4ad310d89a 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/integrations/incoming_webhook_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/integrations/incoming_webhook_spec.ts @@ -67,7 +67,8 @@ describe('Incoming webhook', () => { cy.postIncomingWebhook({url: incomingWebhook.url, data: payload}); - cy.get('#searchBox'). + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input'). wait(TIMEOUTS.HALF_SEC). typeWithForce('findme'). typeWithForce('{enter}'); diff --git a/e2e-tests/cypress/tests/integration/channels/files_and_attachments/disabled_file_upload_spec.js b/e2e-tests/cypress/tests/integration/channels/files_and_attachments/disabled_file_upload_spec.js index 917b84ac50..dacbcf3db1 100644 --- a/e2e-tests/cypress/tests/integration/channels/files_and_attachments/disabled_file_upload_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/files_and_attachments/disabled_file_upload_spec.js @@ -66,13 +66,13 @@ describe('Upload Files - Settings', () => { }); // # Click on the search input - cy.uiGetSearchBox().click(); + cy.uiGetSearchContainer().click(); // * Verify search hint does not have File button - cy.get('#searchbar-help-popup').find('.search-hint__search-type-selector button > .icon-file-text-outline').should('not.exist'); + cy.get('#searchHints').find('.search-hint__search-type-selector button > .icon-file-text-outline').should('not.exist'); // # Search for posts - cy.get('#searchBox').type('sample').type('{enter}'); + cy.uiGetSearchBox().find('input').type('sample').type('{enter}'); // * Verify search results do not have File button cy.get('.files-tab').should('not.exist'); diff --git a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/ctrl_cmd_l_set_message_focus_spec.js b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/ctrl_cmd_l_set_message_focus_spec.js index d119914af9..3589d6b82b 100644 --- a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/ctrl_cmd_l_set_message_focus_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/ctrl_cmd_l_set_message_focus_spec.js @@ -42,7 +42,8 @@ describe('Keyboard Shortcuts', () => { it('MM-T1250 CTRL/CMD+SHIFT+L - Set focus to center channel message box (with SEARCH RHS open)', () => { // # Search - cy.get('#searchBox').click().type('test{enter}'); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').type('test{enter}'); // * Wait for the RHS to open and the search results to appear cy.contains('.sidebar--right__header', 'Search Results').should('be.visible'); diff --git a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/keyboard_shortcuts_1_spec.js b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/keyboard_shortcuts_1_spec.js index 9633e898db..13f12c5367 100644 --- a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/keyboard_shortcuts_1_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/keyboard_shortcuts_1_spec.js @@ -370,11 +370,12 @@ describe('Keyboard Shortcuts', () => { it('MM-T1248 - CTRL/CMD+SHIFT+L - Set focus to center channel message box', () => { // # Open search box to change focus - cy.get('#searchBox').click().should('be.focused').then(() => { - // # Type CTRL/CMD+SHIFT+L - cy.get('body').cmdOrCtrlShortcut('{shift}L'); - cy.uiGetPostTextBox().should('be.focused'); - }); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').should('be.focused'); + + // # Type CTRL/CMD+SHIFT+L + cy.get('body').cmdOrCtrlShortcut('{shift}L'); + cy.uiGetPostTextBox().should('be.focused'); // # Post a message and open RHS const message = `hello${Date.now()}`; diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/center_channel_rhs_overlap_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/center_channel_rhs_overlap_spec.js index e4867e3cde..ba705df9c6 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/center_channel_rhs_overlap_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/center_channel_rhs_overlap_spec.js @@ -718,7 +718,8 @@ describe('Messaging', () => { cy.getLastPostId().then((postId) => { // # Search for the posted message - cy.get('#searchBox').should('be.visible').type(messageX).type('{enter}').wait(TIMEOUTS.HALF_SEC); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').should('be.visible').type(messageX).type('{enter}').wait(TIMEOUTS.HALF_SEC); // # Click on post dot menu so we can edit cy.clickPostDotMenu(postId, 'SEARCH'); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/channel_and_posts_links_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/channel_and_posts_links_spec.js index 2cc4f250e3..7f8e30b435 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/channel_and_posts_links_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/channel_and_posts_links_spec.js @@ -54,7 +54,8 @@ describe('Message permalink', () => { } // # Search for a message in the current channel - cy.get('#searchBox').clear().type('in:town-square').type('{enter}'); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').clear().type('in:town-square').wait(TIMEOUTS.HALF_SEC).type('{enter}{enter}'); // # Jump to first permalink view (most recent message) cy.get('.search-item__jump').first().click(); @@ -71,7 +72,8 @@ describe('Message permalink', () => { }); // # Search for a message in the current channel - cy.get('#searchBox').clear().type('in:town-square').type('{enter}'); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').clear().type('in:town-square').wait(TIMEOUTS.HALF_SEC).type('{enter}{enter}'); // # Jump to first permalink view (most recent message) cy.get('.search-item__jump').first().click(); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/permalink_message_edit_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/permalink_message_edit_spec.js index bc97eaf784..0065144ed0 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/permalink_message_edit_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/permalink_message_edit_spec.js @@ -40,7 +40,8 @@ describe('Permalink message edit', () => { cy.postMessage(searchWord); // # Search for searchWord - cy.get('#searchBox').type(searchWord).type('{enter}'); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').type(searchWord).type('{enter}'); // # Jump to permalink view cy.get('.search-item__jump').first().click(); @@ -69,7 +70,8 @@ describe('Permalink message edit', () => { cy.postMessage('hello'); // # Find searchWord and verify edited post - cy.get('#searchBox').should('be.visible').type(searchWord).type('{enter}'); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').should('be.visible').type(searchWord).type('{enter}'); cy.get('.search-item__jump').first().click(); // # Check if url include the permalink diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/post_pre_header_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/post_pre_header_spec.js index dbffe98a24..b55d87261a 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/post_pre_header_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/post_pre_header_spec.js @@ -186,7 +186,8 @@ describe('Post PreHeader', () => { }); // # Search for the channel. - cy.get('#searchBox').type('test both pinned and saved {enter}'); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').type('test both pinned and saved {enter}'); // * Check that the post pre-header has both pinned and saved links in RHS search results cy.get('#searchContainer').should('be.visible').within(() => { diff --git a/e2e-tests/cypress/tests/integration/channels/notifications/message_bar_spec.js b/e2e-tests/cypress/tests/integration/channels/notifications/message_bar_spec.js index d955266d13..272b0cb94f 100644 --- a/e2e-tests/cypress/tests/integration/channels/notifications/message_bar_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/notifications/message_bar_spec.js @@ -67,8 +67,10 @@ describe('Notifications', () => { }); // # Enter "in:town-square" in the search bar and hit ENTER - cy.get('#searchBox'). + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input'). typeWithForce('in:town-square'). + wait(TIMEOUTS.HALF_SEC). typeWithForce('{enter}'). typeWithForce('{enter}'); diff --git a/e2e-tests/cypress/tests/integration/channels/search/clear_input_spec.js b/e2e-tests/cypress/tests/integration/channels/search/clear_input_spec.js index 1bb559ce2d..17523b0355 100644 --- a/e2e-tests/cypress/tests/integration/channels/search/clear_input_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/search/clear_input_spec.js @@ -25,11 +25,13 @@ describe('Search', () => { // * X should not be visible on empty input cy.uiGetSearchContainer().find('.input-clear-x').should('not.exist'); + cy.uiGetSearchContainer().click(); + // # Write something on the input - cy.uiGetSearchBox().clear().type('abc'); + cy.uiGetSearchBox().find('input').clear().type('abc'); // * The input should contain what we wrote - cy.uiGetSearchBox().should('have.value', 'abc'); + cy.uiGetSearchBox().find('input').should('have.value', 'abc'); // * The X should be visible // # Then click X to clear the input field @@ -50,7 +52,7 @@ describe('Search', () => { // * Verify search input field exists and not search button, as inputs contains placeholder not buttons/icons // and then type in a search text - cy.uiGetSearchBox().click().wait(TIMEOUTS.HALF_SEC).type(searchText); + cy.uiGetSearchBox().click().wait(TIMEOUTS.HALF_SEC).type(searchText + '{enter}'); // # Click on the pinned post button from the header cy.uiGetChannelPinButton().click(); diff --git a/e2e-tests/cypress/tests/integration/channels/search/cleared_search_term_spec.js b/e2e-tests/cypress/tests/integration/channels/search/cleared_search_term_spec.js index ecd363e21b..0d5d504e3d 100644 --- a/e2e-tests/cypress/tests/integration/channels/search/cleared_search_term_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/search/cleared_search_term_spec.js @@ -29,9 +29,11 @@ describe('Search', () => { cy.uiGetSearchContainer().should('be.visible').click(); cy.uiGetSearchBox(). type(`${term}{enter}`). - wait(TIMEOUTS.ONE_SEC). - clear(); - cy.get('#searchbar-help-popup').should('be.visible'); + wait(TIMEOUTS.ONE_SEC); + cy.uiGetSearchContainer().should('be.visible').click(); + cy.uiGetSearchBox().clear(); + + cy.get('#searchHints').should('be.visible'); cy.uiGetSearchContainer().type('{esc}'); // # Verify the Search side bar opens up @@ -43,13 +45,17 @@ describe('Search', () => { cy.uiGetRHS({visible: false}); // # Verify that the cleared search text does not appear on the search box - cy.uiGetSearchBox().should('be.empty'); + cy.uiGetSearchContainer().should('be.visible').click(); + cy.uiGetSearchBox().find('input').should('be.empty'); + cy.uiGetSearchContainer().type('{esc}'); // # Click the pin icon to open the pinned messages RHS cy.uiGetChannelPinButton().click(); cy.uiGetRHS().should('contain', 'Pinned messages'); // # Verify that the Search term input box is still cleared and search term does not reappear when RHS opens - cy.uiGetSearchBox().and('be.empty'); + cy.uiGetSearchContainer().should('be.visible').click(); + cy.uiGetSearchBox().find('input').and('be.empty'); + cy.uiGetSearchContainer().type('{esc}'); }); }); diff --git a/e2e-tests/cypress/tests/integration/channels/search/post_search_display_not_cloud_spec.js b/e2e-tests/cypress/tests/integration/channels/search/post_search_display_not_cloud_spec.js index 0584c4a2eb..6faf008126 100644 --- a/e2e-tests/cypress/tests/integration/channels/search/post_search_display_not_cloud_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/search/post_search_display_not_cloud_spec.js @@ -36,8 +36,11 @@ describe('Search', () => { // # Post a message cy.postMessage(testSearch); + cy.uiGetSearchContainer().click(); + // # Search the world 'hello' that is generated by /test command - cy.get('#searchBox').click().type('hello{enter}').wait(TIMEOUTS.HALF_SEC).should('have.value', 'hello'); + cy.uiGetSearchBox().find('input').type('hello{enter}').wait(TIMEOUTS.HALF_SEC); + cy.get('#search-items-container span').should('contain.text', 'hello'); // # RHS should be visible with search results cy.get('#search-items-container').should('be.visible'); @@ -52,14 +55,10 @@ describe('Search', () => { // # Jump to conversation cy.wrap($result).get('a.search-item__jump').first().click(); - // * Search query clear icon is still present - cy.get('.input-clear.visible').should('be.visible'); + cy.uiGetSearchContainer().click(); // # Hover search query clear icon cy.get('.input-clear-x').first().trigger('mouseover', {force: true}).then(($span) => { - // * Assert that tooltip has shown - cy.wrap($span).should('have.attr', 'aria-describedby', 'InputClearTooltip'); - // # Click the clear query icon cy.wrap($span).click({force: true}); diff --git a/e2e-tests/cypress/tests/integration/channels/search/post_search_display_spec.js b/e2e-tests/cypress/tests/integration/channels/search/post_search_display_spec.js index 408faff5a8..1bb9f64a23 100644 --- a/e2e-tests/cypress/tests/integration/channels/search/post_search_display_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/search/post_search_display_spec.js @@ -37,23 +37,23 @@ describe('Search', () => { // # Post a message cy.postMessage(searchWord); + cy.uiGetSearchContainer().click(); + // * Search word in searchBox and validate searchWord - cy.get('#searchBox').click().type(searchWord + '{enter}').should('have.value', searchWord); + cy.uiGetSearchBox().find('input').type(searchWord + '{enter}'); + + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').should('have.value', searchWord); // # Click on "x" displayed on searchbox - cy.get('#searchbarContainer').should('be.visible').within(() => { - cy.get('#searchFormContainer').find('.input-clear-x').wait(TIMEOUTS.ONE_SEC).click({force: true}); - cy.get('#searchbar-help-popup').should('be.visible'); - cy.get('#searchBox').type('{esc}'); - }); + cy.uiGetSearchBox().find('.input-clear-x').wait(TIMEOUTS.ONE_SEC).click({force: true}); + cy.uiGetSearchBox().find('#searchHints').should('be.visible'); + cy.uiGetSearchBox().type('{esc}'); // # RHS should be visible with search results cy.get('#search-items-container').should('be.visible'); - // # Click on searchbox - cy.get('#searchbarContainer').should('be.visible').within(() => { - cy.get('#searchBox').click(); - }); + cy.uiGetSearchContainer().click(); assertSearchHintFilesOrMessages(); }); @@ -74,29 +74,28 @@ describe('Search', () => { // # Visit town-square. cy.visit(`/${testTeam.name}/channels/town-square`); + cy.uiGetSearchContainer().click(); + // # Search for posts from that user - cy.get('#searchBox').click().type(testSearch, {force: true}); + cy.uiGetSearchBox().find('input').type(testSearch, {force: true}).wait(TIMEOUTS.HALF_SEC); // # Select user from suggestion list cy.contains('.suggestion-list__item', `@${testUser.username}`).scrollIntoView().click({force: true}); // # Verify that search box has the updated query - cy.get('#searchBox').should('have.value', `FROM:${testUser.username} `); + cy.uiGetSearchBox().find('input').should('have.value', `FROM:${testUser.username} `); // # Perform search - cy.get('#searchBox').click().type('{enter}').wait(TIMEOUTS.HALF_SEC); + cy.uiGetSearchBox().find('input').type('{enter}').wait(TIMEOUTS.HALF_SEC); // * Assert that RHS should be visible with search results cy.get('#search-items-container').should('be.visible'); // * Search query clear icon is still present - cy.get('.input-clear.visible').should('be.visible'); + cy.uiGetSearchContainer().click(); // # Hover search query clear icon cy.get('.input-clear-x').first().trigger('mouseover', {force: true}).then(($span) => { - // * Assert that tooltip has shown - cy.wrap($span).should('have.attr', 'aria-describedby', 'InputClearTooltip'); - // # Click the clear query icon cy.wrap($span).click({force: true}); @@ -111,15 +110,13 @@ describe('Search', () => { cy.postMessage('hello'); // # Click on searchbox - cy.get('#searchbarContainer').should('be.visible').within(() => { - cy.get('input#searchBox').should('be.visible').click(); - }); + cy.uiGetSearchContainer().should('be.visible').click(); // * Check the contents in search options assertSearchHintFilesOrMessages(); // # Search for search term in: - cy.get('#searchBox').click().type('in:'); + cy.uiGetSearchBox().find('input').type('in:'); // # Select option from suggestion list cy.get('.suggestion-list__item').first().click({force: true}); @@ -131,31 +128,31 @@ describe('Search', () => { cy.get('.input-clear-x').first().click({force: true}).wait(TIMEOUTS.HALF_SEC); // # Search for search term in:town-square{space} - cy.get('#searchBox').click().type('in:town-square ').wait(TIMEOUTS.HALF_SEC); + cy.uiGetSearchBox().find('input').type('in:town-square ').wait(TIMEOUTS.HALF_SEC); // * Check the hint contents are now visible assertSearchHint(); // # Clear search box - cy.get('.input-clear-x').first().click({force: true}).wait(TIMEOUTS.HALF_SEC); + cy.uiGetSearchBox().get('.input-clear-x').first().click({force: true}).wait(TIMEOUTS.HALF_SEC); // # Search for search term in:town-square{enter} - cy.get('#searchBox').click().type('in:town-square').wait(TIMEOUTS.HALF_SEC); + cy.uiGetSearchBox().find('input').type('in:town-square').wait(TIMEOUTS.HALF_SEC); // * Assert that channel name displays appropriately cy.get('.suggestion-list__item').first().should('contain.text', 'Town Square~town-square'); // # Press enter to register search term - cy.get('#searchBox').click().type('{enter}'); + cy.uiGetSearchBox().find('input').type('{enter}'); // * Check the hint contents are now visible assertSearchHint(); // * Assert that searchBox now includes a trailing space - cy.get('#searchBox').should('have.value', 'in:town-square '); + cy.uiGetSearchBox().find('input').should('have.value', 'in:town-square '); // # Perform the search - cy.get('#searchBox').click().type('{enter}').wait(TIMEOUTS.HALF_SEC); + cy.uiGetSearchBox().find('input').type('{enter}').wait(TIMEOUTS.HALF_SEC); // * Assert autocomplete list is gone cy.get('.suggestion-list__item').should('not.exist'); @@ -167,8 +164,10 @@ describe('Search', () => { // # Post message cy.postMessage(testMessage); + cy.uiGetSearchContainer().click(); + // # Search for `Hell*` - cy.get('input#searchBox').click().type('Hell*{enter}').wait(TIMEOUTS.HALF_SEC); + cy.uiGetSearchBox().find('input').type('Hell*{enter}').wait(TIMEOUTS.HALF_SEC); // # RHS should be visible with search results cy.get('#search-items-container').should('be.visible'); @@ -180,22 +179,9 @@ describe('Search', () => { }); const assertSearchHintFilesOrMessages = () => { - cy.get('#searchbar-help-popup').should('be.visible').within(() => { - cy.get('div span').first().should('have.text', 'What are you searching for?'); - cy.get('div button:first-child span').first().should('have.text', 'Messages'); - cy.get('div button:last-child span').first().should('have.text', 'Files'); - }); + cy.get('#searchHints').should('be.visible'); }; const assertSearchHint = () => { - cy.get('#searchbar-help-popup').should('be.visible').within(() => { - cy.get('div span').first().should('have.text', 'Search options'); - cy.get('div ul li').first().should('have.text', 'From:Messages from a user'); - cy.get('div ul li').eq(1).should('have.text', 'In:Messages in a channel'); - cy.get('div ul li').eq(2).should('have.text', 'On:Messages on a date'); - cy.get('div ul li').eq(3).should('have.text', 'Before:Messages before a date'); - cy.get('div ul li').eq(4).should('have.text', 'After:Messages after a date'); - cy.get('div ul li').eq(5).should('have.text', '—Exclude search terms'); - cy.get('div ul li').last().should('have.text', '""Messages with phrases'); - }); + cy.get('#searchHints').should('be.visible'); }; diff --git a/e2e-tests/cypress/tests/integration/channels/search/results_post_spec.js b/e2e-tests/cypress/tests/integration/channels/search/results_post_spec.js index c172e01316..da96d1db60 100644 --- a/e2e-tests/cypress/tests/integration/channels/search/results_post_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/search/results_post_spec.js @@ -36,7 +36,8 @@ describe('Search', () => { cy.postMessage(message); // # Search for "apple" - cy.get('#searchBox').should('be.visible').type(apple).type('{enter}'); + cy.uiGetSearchContainer().click(); + cy.uiGetSearchBox().find('input').should('be.visible').type(apple).type('{enter}'); // # Get last postId cy.getLastPostId().as('lastPostId'); @@ -46,8 +47,10 @@ describe('Search', () => { verifySearchResult(1, postId, message, apple); }); + cy.uiGetSearchContainer().click(); + // * Type banana on search box but don't hit search - cy.get('#searchBox').clear({force: true}).type(banana, {force: true}); + cy.uiGetSearchBox().find('input').clear({force: true}).type(banana, {force: true}); // * Search result should not change and remain as one result with highlight still on apple cy.get('@lastPostId').then((postId) => { diff --git a/e2e-tests/cypress/tests/integration/channels/search/search_bar_popup_focus_spec.js b/e2e-tests/cypress/tests/integration/channels/search/search_bar_popup_focus_spec.js deleted file mode 100644 index 312c9d077d..0000000000 --- a/e2e-tests/cypress/tests/integration/channels/search/search_bar_popup_focus_spec.js +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -// *************************************************************** -// - [#] indicates a test step (e.g. # Go to a page) -// - [*] indicates an assertion (e.g. * Check the title) -// - Use element ID when selecting an element. Create one if none. -// *************************************************************** - -// Stage: @prod -// Group: @channels @search - -describe('Search', () => { - before(() => { - // # Login as test user and visit off-topic - cy.apiInitSetup({loginAfter: true}).then(({offTopicUrl}) => { - cy.visit(offTopicUrl); - }); - }); - - it('MM-T4945_1 - Search bar popup should be visible when focus changes to one of its buttons', () => { - //# clicks on the search box - cy.uiGetSearchBox().click(); - - //* search bar popup should be visibl - cy.get('#searchbar-help-popup').should('be.visible'); - - //# moves the focus to the next button (in this case Messages) - cy.focused().tab(); - - //* search bar popup should be visible - cy.get('#searchbar-help-popup').should('be.visible'); - }); - - it('MM-T4945_2 - Search bar popup should be hidden when focus is out of search bar items', () => { - //# clicks on the search box - cy.uiGetSearchBox().click(); - - //* search bar popup should be visibl - cy.get('#searchbar-help-popup').should('be.visible'); - - //# press tab three times to move focus out of the search bar componment - cy.focused().tab(); - cy.focused().tab(); - cy.focused().tab(); - - //* now the popup should be closed - cy.get('#searchbar-help-popup').should('not.be.visible'); - }); - - it('MM-T4945_3 - Search bar popup should be open when focus back to search box', () => { - //# clicks on the search box - cy.uiGetSearchBox().click(); - - //* search bar popup should be visibl - cy.get('#searchbar-help-popup').should('be.visible'); - - //# moves the focus to the next button (in this case Messages) - cy.focused().tab(); - - //* search bar popup should be visible - cy.get('#searchbar-help-popup').should('be.visible'); - - //# moves the focus back to the search box - cy.focused().tab({shift: true}); - - //* search bar popup should still be visible - cy.get('#searchbar-help-popup').should('be.visible'); - }); -}); diff --git a/e2e-tests/cypress/tests/integration/channels/search/search_group_message_spec.js b/e2e-tests/cypress/tests/integration/channels/search/search_group_message_spec.js index ce509db415..ddce01ef67 100644 --- a/e2e-tests/cypress/tests/integration/channels/search/search_group_message_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/search/search_group_message_spec.js @@ -51,25 +51,24 @@ describe('Search', () => { // # Post a message cy.postMessage(message); + cy.uiGetSearchContainer().should('be.visible').click(); + //# Type "in:" text in search input - cy.get('#searchBox').type('in:'); + cy.uiGetSearchBox().find('input').type('in:'); const sortedUsernames = groupMembers. map((member) => member.username). sort((a, b) => a.localeCompare(b, 'en', {numeric: true})); //# Search group members in the menu - cy.get('#search-autocomplete__popover').should('be.visible').within(() => { - cy.findAllByTestId('listItem').contains(sortedUsernames.join(',')).click(); - }); + cy.uiGetSearchBox().find('.suggestion-list__main').contains(sortedUsernames.join(',')).click(); //# Press enter to select - cy.get('#searchBox').type('{enter}'); + cy.uiGetSearchBox().find('input').type('{enter}'); //# Search for the message - cy.get('#searchbarContainer').should('be.visible').within(() => { - cy.get('#searchBox').clear().type(`${message}{enter}`); - }); + cy.uiGetSearchContainer().should('be.visible').click(); + cy.uiGetSearchBox().find('input').clear().type(`${message}{enter}`); // * Should return exactly one result from the group channel and matches the message cy.findAllByTestId('search-item-container').should('be.visible').and('have.length', 1).within(() => { @@ -89,25 +88,20 @@ describe('Search', () => { cy.get('.post-image__thumbnail').should('be.visible'); cy.uiGetPostTextBox().clear().type('{enter}'); + cy.uiGetSearchContainer().should('be.visible').click(); + //# Type "in:" text in search input - cy.get('#searchBox').type('in:'); + cy.uiGetSearchBox().find('input').type('in:'); const sortedUsernames = groupMembers. map((member) => member.username). sort((a, b) => a.localeCompare(b, 'en', {numeric: true})); //# Search group members in the menu - cy.get('#search-autocomplete__popover').should('be.visible').within(() => { - cy.findAllByTestId('listItem').contains(sortedUsernames.join(',')).click(); - }); - - //# Press enter to select - cy.get('#searchBox').type('{enter}'); + cy.uiGetSearchBox().find('.suggestion-list__main').contains(sortedUsernames.join(',')).click(); //# Search for the message - cy.get('#searchbarContainer').should('be.visible').within(() => { - cy.get('#searchBox').type(' word-file{enter}'); - }); + cy.uiGetSearchBox().find('input').type('word-file{enter}'); // # Click the files tab cy.get('.files-tab').should('be.visible').click(); diff --git a/e2e-tests/cypress/tests/integration/channels/search/search_user_file_spec.js b/e2e-tests/cypress/tests/integration/channels/search/search_user_file_spec.js index c9fa404d16..c9066a12a7 100644 --- a/e2e-tests/cypress/tests/integration/channels/search/search_user_file_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/search/search_user_file_spec.js @@ -9,6 +9,8 @@ // Group: @channels @search @smoke @filesearch +import * as TIMEOUTS from '../../../fixtures/timeouts'; + /** * create new DM channel * @param {String} text - DM channel name @@ -19,7 +21,7 @@ function createNewDMChannel(channelname) { cy.get('#selectItems input').typeWithForce(channelname); cy.contains('.more-modal__description', channelname).click({force: true}); - cy.get('#saveItems').click(); + cy.get('#saveItems').click().wait(TIMEOUTS.ONE_SEC); } describe('Search in DMs', () => { @@ -57,17 +59,19 @@ describe('Search in DMs', () => { cy.get('.post-image__thumbnail').should('be.visible'); cy.uiGetPostTextBox().clear().type('{enter}'); + cy.uiGetSearchContainer().should('be.visible').click(); + // # Type `in:` in searchbox - cy.get('#searchBox').type('in:'); + cy.uiGetSearchBox().find('input').type('in:'); // # Select user from suggestion list cy.contains('.suggestion-list__item', `@${otherUser.username}`).scrollIntoView().click(); // # Validate searchbox contains the username - cy.get('#searchBox').should('have.value', 'in:@' + otherUser.username + ' '); + cy.uiGetSearchBox().find('input').should('have.value', 'in:@' + otherUser.username + ' '); // # Press Enter in searchbox - cy.get('#searchBox').type('word-file.doc').type('{enter}'); + cy.uiGetSearchBox().find('input').type('word-file').type('{enter}'); // # Click the files tab cy.get('.files-tab').should('be.visible').click(); diff --git a/e2e-tests/cypress/tests/integration/channels/search/search_user_post_spec.js b/e2e-tests/cypress/tests/integration/channels/search/search_user_post_spec.js index ac6fdf65fe..5ca965e8dd 100644 --- a/e2e-tests/cypress/tests/integration/channels/search/search_user_post_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/search/search_user_post_spec.js @@ -59,16 +59,17 @@ describe('Search in DMs', () => { cy.postMessage(message); // # Type `in:` in searchbox - cy.get('#searchBox').type('in:'); + cy.uiGetSearchContainer().should('be.visible').click(); + cy.uiGetSearchBox().find('input').type('in:'); // # Select user from suggestion list cy.contains('.suggestion-list__item', `@${otherUser.username}`).scrollIntoView().click(); // # Validate searchbox contains the username - cy.get('#searchBox').should('have.value', 'in:@' + otherUser.username + ' '); + cy.uiGetSearchBox().find('input').should('have.value', 'in:@' + otherUser.username + ' '); // # Press Enter in searchbox - cy.get('#searchBox').type(message).type('{enter}'); + cy.uiGetSearchBox().find('input').type(message).type('{enter}'); // # Search message in each filtered result cy.get('#search-items-container').find('.search-highlight').each(($el) => { diff --git a/e2e-tests/cypress/tests/integration/channels/search_autocomplete/scroll_spec.js b/e2e-tests/cypress/tests/integration/channels/search_autocomplete/scroll_spec.js deleted file mode 100644 index eb0a1f9fcd..0000000000 --- a/e2e-tests/cypress/tests/integration/channels/search_autocomplete/scroll_spec.js +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -// *************************************************************** -// - [#] indicates a test step (e.g. # Go to a page) -// - [*] indicates an assertion (e.g. * Check the title) -// - Use element ID when selecting an element. Create one if none. -// *************************************************************** - -// Stage: @prod -// Group: @channels @autocomplete @search - -describe('Autocomplete in the search box - scrolling', () => { - const usersCount = 15; - const timestamp = Date.now(); - - before(() => { - // # Create new team for tests - cy.apiCreateTeam(`search-${timestamp}`, `search-${timestamp}`).then(({team}) => { - // # Create pool of users for tests - for (let i = 0; i < usersCount; i++) { - cy.apiCreateUser().then(({user}) => { - cy.apiAddUserToTeam(team.id, user.id); - }); - } - cy.visit(`/${team.name}/channels/off-topic`); - - // # Post a new message to ensure page fully rendered before acting into the searchBox - cy.postMessage('hello'); - }); - }); - - it('MM-T4084 correctly scrolls when the user navigates through options with the keyboard', () => { - // # Type into the searchBox to show list of users - cy.get('#searchBox').type('from:'); - - cy.get('#search-autocomplete__popover .suggestion-list__item').first().as('firstItem'); - cy.get('#search-autocomplete__popover .suggestion-list__item').last().as('lastItem'); - - // * Check that list is scrolled to top - cy.get('@firstItem').should('be.visible'); - cy.get('@lastItem').should('not.be.visible'); - - // # Move to bottom of the list using keyboard - cy.get('body').type('{downarrow}'.repeat(usersCount)); - - // * Check that list is scrolled to bottom - cy.get('@firstItem').should('not.be.visible'); - cy.get('@lastItem').should('be.visible'); - - // # Move to top of the list using keyboard - cy.get('body').type('{uparrow}'.repeat(usersCount)); - - // * Check that list is scrolled to top - cy.get('@firstItem').should('be.visible'); - cy.get('@lastItem').should('not.be.visible'); - }); -}); diff --git a/e2e-tests/cypress/tests/integration/channels/search_filter/helpers.js b/e2e-tests/cypress/tests/integration/channels/search_filter/helpers.js index a52159c616..d153f3b08a 100644 --- a/e2e-tests/cypress/tests/integration/channels/search_filter/helpers.js +++ b/e2e-tests/cypress/tests/integration/channels/search_filter/helpers.js @@ -12,13 +12,13 @@ import {getRandomId} from '../../../utils'; export function searchAndValidate(query, expectedResults = []) { // # Enter in search query, and hit enter + cy.uiGetSearchContainer().should('be.visible').click(); cy.uiGetSearchBox(). clear(). wait(TIMEOUTS.HALF_SEC). type(query). wait(TIMEOUTS.HALF_SEC). - type('{enter}'). - should('be.empty'); + type('{enter}'); cy.get('#loadingSpinner').should('not.exist'); diff --git a/e2e-tests/cypress/tests/integration/channels/search_filter/input_spec.js b/e2e-tests/cypress/tests/integration/channels/search_filter/input_spec.js index 59cd0d932a..a348229642 100644 --- a/e2e-tests/cypress/tests/integration/channels/search_filter/input_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/search_filter/input_spec.js @@ -53,49 +53,53 @@ describe('Search Date Filter', () => { const today = Cypress.dayjs().format('YYYY-MM-DD'); // # Type before: in search field - cy.get('#searchBox').clear().type('before:'); + cy.uiGetSearchContainer().should('be.visible').click(); + cy.uiGetSearchBox().find('input').clear().type('before:'); // * Day picker should be visible - cy.get('.DayPicker'). + cy.get('.rdp'). as('dayPicker'). should('be.visible'); // # Select today's day cy.get('@dayPicker'). - find('.DayPicker-Day--today').click(); + find('.rdp-day_today').click(); cy.get('@dayPicker').should('not.exist'); // * Verify date picker output gets put into field as expected date - cy.get('#searchBox').should('have.value', `before:${today} `); + cy.uiGetSearchBox().find('input').should('have.value', `before:${today} `); // # Click "x" to the right of the search term - cy.get('#searchFormContainer').find('.input-clear-x').click({force: true}); + cy.uiGetSearchBox().find('.input-clear-x').click({force: true}); // * The "x" to clear the search query has disappeared - cy.get('#searchBox').should('have.value', ''); + cy.uiGetSearchBox().find('input').should('have.value', ''); + + cy.uiGetSearchContainer().should('be.visible').click(); }); it('MM-T3997 Backspace after last character of filter makes calendar reappear', () => { const today = Cypress.dayjs().format('YYYY-MM-DD'); // # Type before: in search field - cy.get('#searchBox').clear().type('before:'); + cy.uiGetSearchContainer().should('be.visible').click(); + cy.uiGetSearchBox().find('input').clear().type('before:'); // * Date picker should be visible - cy.get('.DayPicker'). + cy.get('.rdp'). as('dayPicker'). should('be.visible'); // # Select today's day cy.get('@dayPicker'). - find('.DayPicker-Day--today').click(); + find('.rdp-day_today').click(); // * Date picker should disappear cy.get('@dayPicker').should('not.exist'); // # Hit backspace with focus right after the date - cy.get('#searchBox'). + cy.uiGetSearchBox().find('input'). should('have.value', `before:${today} `). focus(). type('{backspace}'); @@ -122,7 +126,8 @@ describe('Search Date Filter', () => { const queryString = `on:${Cypress.dayjs().format('YYYY-MM-DD')} ${commonText}`; // * Filter can be removed with keyboard - cy.get('#searchBox'). + cy.uiGetSearchContainer().should('be.visible').click(); + cy.uiGetSearchBox().find('input'). clear(). wait(TIMEOUTS.HALF_SEC). type(queryString). @@ -130,10 +135,10 @@ describe('Search Date Filter', () => { should('have.value', ''); // # Enter query to search box and then click "x" to the right of the search term - cy.get('#searchBox').clear().wait(TIMEOUTS.HALF_SEC).type(queryString); - cy.get('#searchFormContainer').find('.input-clear-x').click({force: true}); + cy.uiGetSearchBox().find('input').clear().wait(TIMEOUTS.HALF_SEC).type(queryString); + cy.uiGetSearchBox().find('.input-clear-x').click({force: true}); // * The "x" to clear the search query has disappeared - cy.get('#searchBox').should('have.value', ''); + cy.uiGetSearchBox().find('input').should('have.value', ''); }); }); diff --git a/e2e-tests/cypress/tests/support/ui/search.ts b/e2e-tests/cypress/tests/support/ui/search.ts index c2eccdb89c..56ea4f7697 100644 --- a/e2e-tests/cypress/tests/support/ui/search.ts +++ b/e2e-tests/cypress/tests/support/ui/search.ts @@ -5,7 +5,8 @@ import {ChainableT} from 'tests/types'; function uiSearchPosts(searchTerm: string) { // # Enter the search terms and hit enter to start the search - cy.get('#searchBox').clear().type(searchTerm).type('{enter}'); + cy.get('#searchFormContainer').should('be.visible').click(); + cy.get('#searchBox').find('input').clear().type(searchTerm).type('{enter}'); // * Wait for the RHS to open and the search results to appear cy.contains('.sidebar--right__header', 'Search Results').should('be.visible'); diff --git a/webapp/channels/src/actions/command.test.js b/webapp/channels/src/actions/command.test.js index da0e7c159d..0313f0624b 100644 --- a/webapp/channels/src/actions/command.test.js +++ b/webapp/channels/src/actions/command.test.js @@ -129,6 +129,7 @@ const initialState = { rhs: { rhsState: null, searchTerms: '', + searchType: '', }, }, }; @@ -150,6 +151,7 @@ describe('executeCommand', () => { {type: 'UPDATE_RHS_SEARCH_TERMS', terms: 'foo bar'}, {type: 'UPDATE_RHS_STATE', state: 'search'}, {type: 'UPDATE_RHS_SEARCH_RESULTS_TERMS', terms: ''}, + {type: 'UPDATE_RHS_SEARCH_RESULTS_TYPE', searchType: ''}, {type: 'SEARCH_POSTS_REQUEST', isGettingMore: false}, {type: 'SEARCH_FILES_REQUEST', isGettingMore: false}, ]); diff --git a/webapp/channels/src/actions/post_actions.test.ts b/webapp/channels/src/actions/post_actions.test.ts index 2d0101cb08..394fd160d7 100644 --- a/webapp/channels/src/actions/post_actions.test.ts +++ b/webapp/channels/src/actions/post_actions.test.ts @@ -192,6 +192,7 @@ describe('Actions.Posts', () => { }, rhs: { searchTerms: '', + searchType: '', filesSearchExtFilter: [], }, }, @@ -344,6 +345,7 @@ describe('Actions.Posts', () => { {terms: 'hello', type: 'UPDATE_RHS_SEARCH_TERMS'}, {state: 'search', type: 'UPDATE_RHS_STATE'}, {terms: '', type: 'UPDATE_RHS_SEARCH_RESULTS_TERMS'}, + {searchType: '', type: 'UPDATE_RHS_SEARCH_RESULTS_TYPE'}, {isGettingMore: false, type: 'SEARCH_POSTS_REQUEST'}, {isGettingMore: false, type: 'SEARCH_FILES_REQUEST'}, ]); diff --git a/webapp/channels/src/actions/views/rhs.test.ts b/webapp/channels/src/actions/views/rhs.test.ts index 11f23f375e..65ec5a3fb0 100644 --- a/webapp/channels/src/actions/views/rhs.test.ts +++ b/webapp/channels/src/actions/views/rhs.test.ts @@ -125,6 +125,7 @@ describe('rhs view actions', () => { rhs: { rhsState: null, filesSearchExtFilter: [] as string[], + searchType: '', }, posts: { editingPost: { @@ -219,7 +220,12 @@ describe('rhs view actions', () => { describe('performSearch', () => { // timezone offset in seconds - const timeZoneOffset = getBrowserUtcOffset() * 60; + let timeZoneOffset = getBrowserUtcOffset() * 60; + + // Avoid problems with negative cero + if (timeZoneOffset === 0) { + timeZoneOffset = 0; + } test('it dispatches searchPosts correctly', () => { const terms = '@here test search'; @@ -276,6 +282,7 @@ describe('rhs view actions', () => { views: { rhs: { searchTerms: terms, + searchType: 'messages', filesSearchExtFilter: [] as string[], }, }, @@ -292,6 +299,10 @@ describe('rhs view actions', () => { type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TERMS, terms, }); + compareStore.dispatch({ + type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TYPE, + searchType: 'messages', + }); compareStore.dispatch(performSearch(terms)); expect(store.getActions()).toEqual(compareStore.getActions()); @@ -861,6 +872,7 @@ describe('rhs view actions', () => { views: { rhs: { searchTerms: terms, + searchType: 'messages', filesSearchExtFilter: [] as string[], }, }, @@ -875,6 +887,10 @@ describe('rhs view actions', () => { type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TERMS, terms, }); + compareStore.dispatch({ + type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TYPE, + searchType: 'messages', + }); compareStore.dispatch(performSearch(terms)); expect(store.getActions()).toEqual(compareStore.getActions()); diff --git a/webapp/channels/src/actions/views/rhs.ts b/webapp/channels/src/actions/views/rhs.ts index 6aa582d260..2e3dd31ae9 100644 --- a/webapp/channels/src/actions/views/rhs.ts +++ b/webapp/channels/src/actions/views/rhs.ts @@ -26,7 +26,14 @@ import {getCurrentUser, getCurrentUserMentionKeys} from 'mattermost-redux/select import type {ActionFunc, ActionFuncAsync, ThunkActionFunc} from 'mattermost-redux/types/actions'; import {trackEvent} from 'actions/telemetry_actions.jsx'; -import {getSearchTerms, getRhsState, getPluggableId, getFilesSearchExtFilter, getPreviousRhsState} from 'selectors/rhs'; +import { + getSearchType, + getSearchTerms, + getRhsState, + getPluggableId, + getFilesSearchExtFilter, + getPreviousRhsState, +} from 'selectors/rhs'; import {SidebarSize} from 'components/resizable_sidebar/constants'; @@ -187,6 +194,13 @@ function updateSearchResultsTerms(terms: string) { }; } +function updateSearchResultsType(searchType: string) { + return { + type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TYPE, + searchType, + }; +} + export function performSearch(terms: string, isMentionSearch?: boolean): ThunkActionFunc { return (dispatch, getState) => { let searchTerms = terms; @@ -240,6 +254,7 @@ export function showSearchResults(isMentionSearch = false): ThunkActionFunc { flex={1} alignment='center' > - + ); }; diff --git a/webapp/channels/src/components/new_search/extension_suggestions.test.tsx b/webapp/channels/src/components/new_search/extension_suggestions.test.tsx new file mode 100644 index 0000000000..960926e86f --- /dev/null +++ b/webapp/channels/src/components/new_search/extension_suggestions.test.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import { + renderWithContext, + screen, +} from 'tests/react_testing_utils'; + +import ExtensionSuggestion from './extension_suggestions'; + +describe('components/new_search/ExtensionSuggestion', () => { + const baseProps = { + item: {type: 'test-type', label: 'test-label', value: 'test-value'}, + term: 'test', + matchedPretext: 'test', + isSelection: false, + onClick: jest.fn(), + onMouseMove: jest.fn(), + }; + + test('should show the file-type as label and the value as extension whenever is not a know filetype', () => { + renderWithContext(); + expect(screen.getByText('test-type')).toBeInTheDocument(); + expect(screen.getByText('(.test-value)')).toBeInTheDocument(); + }); + + test('should show the name of the type of file whenever it knows the filetype', () => { + const props = {...baseProps, item: {...baseProps.item, type: 'pdf', value: 'pdf'}}; + renderWithContext(); + expect(screen.getByText('Acrobat')).toBeInTheDocument(); + expect(screen.getByText('(.pdf)')).toBeInTheDocument(); + }); + + test('should pass the right data on click', () => { + const props = {...baseProps}; + renderWithContext(); + screen.getByText('test-type').click(); + expect(props.onClick).toHaveBeenCalledWith('test-value', 'test'); + }); + + test('should have selected class whenever is selected', () => { + const props = {...baseProps, isSelection: true}; + renderWithContext(); + expect(screen.getByText('test-type')).toHaveClass('selected'); + }); +}); diff --git a/webapp/channels/src/components/new_search/extension_suggestions.tsx b/webapp/channels/src/components/new_search/extension_suggestions.tsx new file mode 100644 index 0000000000..09d0d41345 --- /dev/null +++ b/webapp/channels/src/components/new_search/extension_suggestions.tsx @@ -0,0 +1,131 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React, {useCallback} from 'react'; +import type {MessageDescriptor} from 'react-intl'; +import {useIntl, defineMessages} from 'react-intl'; +import styled from 'styled-components'; + +import type {SuggestionProps} from 'components/suggestion/suggestion'; + +import {getCompassIconClassName} from 'utils/utils'; + +import type {ExtensionItem} from './extension_suggestions_provider'; + +const SearchFileExtensionSuggestionContainer = styled.div` + display: flex; + align-items: center; + padding: 8px 2.4rem; + &.selected, + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + } + + .file-icon { + background-size: 16px 20px; + width: 24px; + height: 24px; + font-size: 18px; + margin-right: 12px; + display: flex; + align-items: center; + &.icon-file-excel-outline { + color: #339970; + } + &.icon-file-powerpoint-outline { + color: #e07315; + } + &.icon-file-pdf-outline { + color: #c43133; + } + &.icon-file-image-outline, + &.icon-file-audio-outline, + &.icon-file-video-outline, + &.icon-file-word-outline { + color: #5d89ea; + } + } +`; + +const ExtensionText = styled.span` + margin-left: 4px; +`; + +const messages: Record = + defineMessages({ + pdf: { + id: 'file_type.pdf', + defaultMessage: 'Acrobat', + }, + word: { + id: 'file_type.word', + defaultMessage: 'Word Document', + }, + image: { + id: 'file_type.image', + defaultMessage: 'Image', + }, + audio: { + id: 'file_type.audio', + defaultMessage: 'Audio', + }, + video: { + id: 'file_type.video', + defaultMessage: 'Video', + }, + presentation: { + id: 'file_type.presentation', + defaultMessage: 'Powerpoint Presentation', + }, + spreadsheet: { + id: 'file_type.spreadsheet', + defaultMessage: 'Excel spreadsheet', + }, + code: { + id: 'file_type.code', + defaultMessage: 'Code file', + }, + patch: { + id: 'file_type.patch', + defaultMessage: 'Patch file', + }, + svg: { + id: 'file_type.svg', + defaultMessage: 'Vector graphics', + }, + text: { + id: 'file_type.text', + defaultMessage: 'Text file', + }, + }); + +const SearchFileExtensionSuggestion = React.forwardRef< +HTMLDivElement, +SuggestionProps +>(({item, onClick, matchedPretext, isSelection}, ref) => { + const intl = useIntl(); + + const optionClicked = useCallback(() => { + onClick(item.value, matchedPretext); + }, [onClick, item.value, matchedPretext]); + + const labelName = messages[item.type] ? + intl.formatMessage(messages[item.type]) : + item.type; + + return ( + +
+ {labelName} + {`(.${item.value})`} + + ); +}); +SearchFileExtensionSuggestion.displayName = 'SearchFileExtensionSuggestion'; + +export default SearchFileExtensionSuggestion; diff --git a/webapp/channels/src/components/new_search/extension_suggestions_provider.test.tsx b/webapp/channels/src/components/new_search/extension_suggestions_provider.test.tsx new file mode 100644 index 0000000000..a403e557b3 --- /dev/null +++ b/webapp/channels/src/components/new_search/extension_suggestions_provider.test.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {SearchFileExtensionProvider} from './extension_suggestions_provider'; + +describe('components/new_search/SearchFileExtensionProvider', () => { + test('should not provide any file extensions whenever there is not pretext with ext: in it', () => { + const provider = new SearchFileExtensionProvider(); + const callback = jest.fn(); + provider.handlePretextChanged('', callback); + expect(callback).not.toHaveBeenCalled(); + }); + + test('should provide a predetermined file extensions whenever there is not pretext', () => { + const provider = new SearchFileExtensionProvider(); + const callback = jest.fn(); + provider.handlePretextChanged('ext:', callback); + expect(callback).toHaveBeenCalledWith({ + component: expect.any(Object), + items: [ + {label: 'text', type: 'text', value: 'txt'}, + {label: 'word', type: 'word', value: 'docx'}, + {label: 'spreadsheet', type: 'spreadsheet', value: 'xlsx'}, + {label: 'presentation', type: 'presentation', value: 'pptx'}, + {label: 'pdf', type: 'pdf', value: 'pdf'}, + {label: 'image', type: 'image', value: 'png'}, + {label: 'image', type: 'image', value: 'jpg'}, + {label: 'audio', type: 'audio', value: 'mp3'}, + {label: 'video', type: 'video', value: 'mp4'}, + ], + matchedPretext: '', + terms: ['txt', 'docx', 'xlsx', 'pptx', 'pdf', 'png', 'jpg', 'mp3', 'mp4'], + }); + }); + + test('should provide a filtered set of file extensions whenever there is pretext', () => { + const provider = new SearchFileExtensionProvider(); + const callback = jest.fn(); + provider.handlePretextChanged('ext:t', callback); + expect(callback).toHaveBeenCalledWith({ + component: expect.any(Object), + items: [ + {label: 'tex', type: 'code', value: 'tex'}, + {label: 'thor', type: 'code', value: 'thor'}, + {label: 'tif', type: 'image', value: 'tif'}, + {label: 'tiff', type: 'image', value: 'tiff'}, + {label: 'txt', type: 'text', value: 'txt'}, + ], + matchedPretext: 't', + terms: ['tex', 'thor', 'tif', 'tiff', 'txt'], + }); + }); +}); diff --git a/webapp/channels/src/components/new_search/extension_suggestions_provider.tsx b/webapp/channels/src/components/new_search/extension_suggestions_provider.tsx new file mode 100644 index 0000000000..f0514b3618 --- /dev/null +++ b/webapp/channels/src/components/new_search/extension_suggestions_provider.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ResultsCallback} from 'components/suggestion/provider'; +import Provider from 'components/suggestion/provider'; + +import Constants from 'utils/constants'; + +import SearchFileExtensionSuggestion from './extension_suggestions'; + +export type ExtensionItem = { + label: string; + type: string; + value: string; +}; + +const globalExtensions: ExtensionItem[] = []; +Constants.TEXT_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'text', value: extension})); +Constants.IMAGE_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'image', value: extension})); +Constants.AUDIO_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'audio', value: extension})); +Constants.VIDEO_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'video', value: extension})); +Constants.PRESENTATION_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'presentation', value: extension})); +Constants.SPREADSHEET_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'spreadsheet', value: extension})); +Constants.WORD_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'word', value: extension})); +Constants.CODE_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'code', value: extension})); +Constants.PDF_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'pdf', value: extension})); +Constants.PATCH_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'patch', value: extension})); +Constants.SVG_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'svg', value: extension})); +globalExtensions.sort((a, b) => a.label.localeCompare(b.label)); + +export class SearchFileExtensionProvider extends Provider { + handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { + const captured = (/\b(?:ext):\s*(\S*)$/i).exec(pretext.toLowerCase()); + if (captured) { + const matchedPretext = captured[1]; + + let extensions: ExtensionItem[] = [...globalExtensions]; + if (matchedPretext.length > 0) { + extensions = extensions.filter((extension) => extension.label.startsWith(matchedPretext.toLowerCase())); + extensions = extensions.slice(0, 10); + } else { + extensions = [ + {label: 'text', type: 'text', value: 'txt'}, + {label: 'word', type: 'word', value: 'docx'}, + {label: 'spreadsheet', type: 'spreadsheet', value: 'xlsx'}, + {label: 'presentation', type: 'presentation', value: 'pptx'}, + {label: 'pdf', type: 'pdf', value: 'pdf'}, + {label: 'image', type: 'image', value: 'png'}, + {label: 'image', type: 'image', value: 'jpg'}, + {label: 'audio', type: 'audio', value: 'mp3'}, + {label: 'video', type: 'video', value: 'mp4'}, + ]; + } + + const terms = extensions.map((extension) => extension.value); + + resultsCallback({ + matchedPretext, + terms, + items: extensions, + component: SearchFileExtensionSuggestion, + }); + } + + return Boolean(captured); + } + + allowDividers() { + return false; + } + + presentationType() { + return 'text'; + } +} diff --git a/webapp/channels/src/components/new_search/hooks.tsx b/webapp/channels/src/components/new_search/hooks.tsx new file mode 100644 index 0000000000..e2a1c1daf5 --- /dev/null +++ b/webapp/channels/src/components/new_search/hooks.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useRef, useEffect, useMemo} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import type {Channel} from '@mattermost/types/channels'; +import type {ServerError} from '@mattermost/types/errors'; + +import {autocompleteChannelsForSearch} from 'actions/channel_actions'; +import {autocompleteUsersInTeam} from 'actions/user_actions'; + +import type {ProviderResult} from 'components/suggestion/provider'; +import type Provider from 'components/suggestion/provider'; +import SearchChannelProvider from 'components/suggestion/search_channel_provider'; +import SearchDateProvider from 'components/suggestion/search_date_provider'; +import SearchUserProvider from 'components/suggestion/search_user_provider'; + +import {SearchFileExtensionProvider} from './extension_suggestions_provider'; + +const useSearchSuggestions = (searchType: string, searchTerms: string, caretPosition: number, getCaretPosition: () => number, setSelectedOption: (idx: number) => void): [ProviderResult|null, React.ReactNode] => { + const dispatch = useDispatch(); + + const [providerResults, setProviderResults] = useState|null>(null); + const [suggestionsHeader, setSuggestionsHeader] = useState(); + + const suggestionProviders = useRef([ + new SearchDateProvider(), + new SearchChannelProvider((term: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => dispatch(autocompleteChannelsForSearch(term, success, error))), + new SearchUserProvider((username: string) => dispatch(autocompleteUsersInTeam(username))), + new SearchFileExtensionProvider(), + ]); + + const headers = useMemo(() => [ + , + , + , + , + ], []); + + useEffect(() => { + setProviderResults(null); + if (searchType !== '' && searchType !== 'messages' && searchType !== 'files') { + return; + } + + const partialSearchTerms = searchTerms.slice(0, caretPosition); + if (searchTerms.length > caretPosition && searchTerms[caretPosition] !== ' ') { + return; + } + + if (caretPosition > 0 && searchTerms[caretPosition - 1] === ' ') { + return; + } + + suggestionProviders.current.forEach((provider, idx) => { + provider.handlePretextChanged(partialSearchTerms, (res: ProviderResult) => { + if (idx === 3 && searchType !== 'files') { + return; + } + if (caretPosition !== getCaretPosition()) { + return; + } + res.items = res.items.slice(0, 10); + res.terms = res.terms.slice(0, 10); + setProviderResults(res); + setSelectedOption(0); + setSuggestionsHeader(headers[idx]); + }); + }); + }, [searchTerms, searchType, caretPosition]); + + return [providerResults, suggestionsHeader]; +}; + +export default useSearchSuggestions; diff --git a/webapp/channels/src/components/new_search/new_search.test.tsx b/webapp/channels/src/components/new_search/new_search.test.tsx new file mode 100644 index 0000000000..e41745cc1b --- /dev/null +++ b/webapp/channels/src/components/new_search/new_search.test.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import { + renderWithContext, + screen, + fireEvent, + act, +} from 'tests/react_testing_utils'; + +import NewSearch from './new_search'; + +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux') as typeof import('react-redux'), + useDispatch: () => mockDispatch, +})); + +describe('components/new_search/NewSearch', () => { + test('should open the search box on click search', () => { + renderWithContext(); + expect(screen.queryByText('Messages')).not.toBeInTheDocument(); + screen.getByText('Search').click(); + expect(screen.getByText('Messages')).toBeInTheDocument(); + }); + + test('should open the search box when pressing any key differnt than tab', () => { + renderWithContext(); + expect(screen.queryByText('Messages')).not.toBeInTheDocument(); + fireEvent.keyDown(screen.getByText('Search'), {key: 'Tab', code: 'Tab', keyCode: 9, charCode: 9}); + expect(screen.queryByText('Messages')).not.toBeInTheDocument(); + fireEvent.keyDown(screen.getByText('Search'), {key: 'a', code: 'KeyA', keyCode: 65, charCode: 65}); + expect(screen.getByText('Messages')).toBeInTheDocument(); + }); + + test('should close the search box on click outside the searchbox', () => { + renderWithContext(
{'Outside'}
); + expect(screen.queryByText('Messages')).not.toBeInTheDocument(); + act(() => { + screen.getByText('Search').click(); + }); + expect(screen.getByText('Messages')).toBeInTheDocument(); + act(() => { + screen.getByText('Outside').click(); + }); + expect(screen.queryByText('Messages')).not.toBeInTheDocument(); + }); + + test('should close the search box on Esc key is pressed', () => { + renderWithContext(); + expect(screen.queryByText('Messages')).not.toBeInTheDocument(); + act(() => { + screen.getByText('Search').click(); + }); + expect(screen.getByText('Messages')).toBeInTheDocument(); + act(() => { + fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Escape', code: 'Escape', keyCode: 27, charCode: 27}); + }); + expect(screen.queryByText('Messages')).not.toBeInTheDocument(); + }); + + test('should close on search after calling dispatch', () => { + renderWithContext(); + expect(screen.queryByText('Messages')).not.toBeInTheDocument(); + act(() => { + screen.getByText('Search').click(); + }); + expect(screen.getByText('Messages')).toBeInTheDocument(); + act(() => { + fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Enter', code: 'Enter', keyCode: 13, charCode: 13}); + }); + expect(screen.queryByText('Messages')).not.toBeInTheDocument(); + expect(mockDispatch).toHaveBeenCalledWith({searchType: 'messages', type: 'UPDATE_RHS_SEARCH_TYPE'}); + expect(mockDispatch).toHaveBeenCalledWith({terms: '', type: 'UPDATE_RHS_SEARCH_TERMS'}); + expect(mockDispatch).toHaveBeenCalledTimes(3); + }); + + test('should open the search ctrl+shift+f is press on web app', () => { + renderWithContext(
{'Outside'}
); + expect(screen.queryByText('Messages')).not.toBeInTheDocument(); + act(() => { + fireEvent.keyDown(screen.getByText('Outside'), {key: 'f', code: 'KeyF', keyCode: 70, charCode: 70, ctrlKey: true, shiftKey: true}); + }); + expect(screen.getByText('Messages')).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/new_search/new_search.tsx b/webapp/channels/src/components/new_search/new_search.tsx new file mode 100644 index 0000000000..c12b045ac7 --- /dev/null +++ b/webapp/channels/src/components/new_search/new_search.tsx @@ -0,0 +1,280 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState, useRef, useCallback} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector, useDispatch} from 'react-redux'; +import styled from 'styled-components'; + +import {getCurrentChannelNameForSearchShortcut} from 'mattermost-redux/selectors/entities/channels'; + +import {updateSearchTerms, showSearchResults, updateSearchType} from 'actions/views/rhs'; +import {getSearchButtons} from 'selectors/plugins'; +import {getSearchTerms, getSearchType} from 'selectors/rhs'; + +import Popover from 'components/widgets/popover'; + +import Constants from 'utils/constants'; +import * as Keyboard from 'utils/keyboard'; +import {isServerVersionGreaterThanOrEqualTo} from 'utils/server_version'; +import {isDesktopApp, getDesktopVersion, isMacApp} from 'utils/user_agent'; + +import SearchBox from './search_box'; + +const PopoverStyled = styled(Popover)` + min-width: 600px; + left: -90px; + top: -12px; + border-radius: 12px; + max-height: 90vh; + overflow-y: auto; + + .popover-content { + padding: 0px; + } +`; + +const SearchTypeBadge = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 2px 2px 2px 7px; + border-radius: 4px; + margin: 0 6px; + background: rgba(var(--sidebar-text-rgb), 0.08); + color: var(--sidebar-text); + font-size: 10px; + font-weight: 700; + + .icon-close { + padding: 2px; + cursor: pointer; + } + + &:hover { + background: rgba(v(button-bg-rgb), 0.16); + } +`; + +const CloseIcon = styled.div` + position: absolute; + top: 0; + right: 2px; + display: flex; + width: 2.4rem; + height: 100%; + align-items: center; + justify-content: center; + margin: 0; + cursor: pointer; + font-size: 16px; + visibility: visible; + transition: opacity 0.12s easy-out; +`; + +const NewSearchContainer = styled.div` + display: flex; + position: relative; + align-items: center; + height: 28px; + width: 100%; + background-color: rgba(var(--sidebar-text-rgb), 0.08); + color: rgba(var(--sidebar-text-rgb), 0.64); + font-size: 12px; + font-weight: 500; + border-radius: var(--radius-s); + border: none; + padding: 4px; + cursor: pointer; + &:hover { + background-color: rgba(var(--sidebar-text-rgb), 0.16); + color: rgba(var(--sidebar-text-rgb), 0.88); + } +`; + +const NewSearch = (): JSX.Element => { + const currentChannelName = useSelector(getCurrentChannelNameForSearchShortcut); + const searchTerms = useSelector(getSearchTerms) || ''; + const searchType = useSelector(getSearchType) || ''; + const pluginSearch = useSelector(getSearchButtons); + + const dispatch = useDispatch(); + const [focused, setFocused] = useState(false); + const [currentChannel, setCurrentChannel] = useState(''); + const searchBoxRef = useRef(null); + + useEffect(() => { + const isDesktop = isDesktopApp() && isServerVersionGreaterThanOrEqualTo(getDesktopVersion(), '4.7.0'); + + const handleKeyDown = (e: KeyboardEvent) => { + if (Keyboard.isKeyPressed(e, Constants.KeyCodes.ESCAPE)) { + e.preventDefault(); + setCurrentChannel(''); + setFocused(false); + } + + if (Keyboard.cmdOrCtrlPressed(e) && Keyboard.isKeyPressed(e, Constants.KeyCodes.F6)) { + e.preventDefault(); + setCurrentChannel(''); + setFocused(false); + } + + if (Keyboard.cmdOrCtrlPressed(e) && Keyboard.isKeyPressed(e, Constants.KeyCodes.F)) { + if (!isDesktop && !e.shiftKey) { + return; + } + + // Special case for Mac Desktop xApp where Ctrl+Cmd+F triggers full screen view + if (isMacApp() && e.ctrlKey) { + return; + } + + e.preventDefault(); + setCurrentChannel(currentChannelName || ''); + setFocused(true); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [currentChannelName]); + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (searchBoxRef.current) { + if (e.target !== searchBoxRef.current && !searchBoxRef.current.contains(e.target as Node)) { + setFocused(false); + setCurrentChannel(''); + } + } + }; + + document.addEventListener('click', handleClick, {capture: true}); + return () => { + document.removeEventListener('click', handleClick); + }; + }, []); + + const closeSearchBox = useCallback(() => { + setFocused(false); + setCurrentChannel(''); + }, []); + + const openSearchBox = useCallback(() => { + setFocused(true); + }, []); + + const openSearchBoxOnKeyPress = useCallback( + (e: React.KeyboardEvent) => { + if (Keyboard.isKeyPressed(e, Constants.KeyCodes.TAB)) { + return; + } + if (Keyboard.cmdOrCtrlPressed(e) && Keyboard.isKeyPressed(e, Constants.KeyCodes.F6)) { + setFocused(false); + return; + } + openSearchBox(); + }, + [openSearchBox], + ); + + const runSearch = useCallback( + (searchType: string, searchTerms: string) => { + dispatch(updateSearchType(searchType)); + dispatch(updateSearchTerms(searchTerms)); + + if (searchType === '' || searchType === 'messages' || searchType === 'files') { + dispatch(showSearchResults(false)); + } else { + pluginSearch.forEach((pluginData: any) => { + if (pluginData.pluginId === searchType) { + pluginData.action(searchTerms); + } + }); + } + setFocused(false); + setCurrentChannel(''); + }, + [pluginSearch], + ); + + const onClose = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + dispatch(updateSearchType('')); + dispatch(updateSearchTerms('')); + }, []); + + const clearSearchType = useCallback(() => dispatch(updateSearchType('')), []); + + return ( + + + {(searchType === 'messages' || searchType === 'files') && ( + + {searchType === 'messages' && ( + + )} + {searchType === 'files' && ( + + )} + + + )} + {searchTerms && {searchTerms}} + {searchTerms && ( + + + + )} + {!searchTerms && ( + + )} + {focused && ( + + + + )} + + ); +}; + +export default NewSearch; diff --git a/webapp/channels/src/components/new_search/search_box.test.tsx b/webapp/channels/src/components/new_search/search_box.test.tsx new file mode 100644 index 0000000000..6c4c2d5e91 --- /dev/null +++ b/webapp/channels/src/components/new_search/search_box.test.tsx @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import { + renderWithContext, + screen, + fireEvent, +} from 'tests/react_testing_utils'; + +import SearchBox from './search_box'; + +describe('components/new_search/SearchBox', () => { + const baseProps = { + onClose: jest.fn(), + onSearch: jest.fn(), + initialSearchTerms: '', + initialSearchType: 'messages', + }; + + test('should have the focus on the input field', () => { + renderWithContext(); + expect(screen.getByPlaceholderText('Search messages')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Search messages')).toHaveFocus(); + }); + + test('should have set the initial search terms', () => { + const props = {...baseProps, initialSearchTerms: 'test'}; + renderWithContext(); + expect(screen.getByPlaceholderText('Search messages')).toHaveValue('test'); + }); + + test('should have the focus on the input field after switching search type', () => { + renderWithContext(); + screen.getByText('Files').click(); + expect(screen.getByPlaceholderText('Search files')).toHaveFocus(); + }); + + test('should see files hints when i click on files', () => { + renderWithContext(); + expect(screen.getByText('From:')).toBeInTheDocument(); + expect(screen.queryByText('Ext:')).not.toBeInTheDocument(); + screen.getByText('Files').click(); + expect(screen.getByText('Ext:')).toBeInTheDocument(); + }); + + test('should call close on esc keydown', () => { + renderWithContext(); + fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Escape', code: 'Escape'}); + expect(baseProps.onClose).toBeCalledTimes(1); + }); + + test('should call search on enter keydown', () => { + renderWithContext(); + fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Enter', code: 'Enter'}); + expect(baseProps.onSearch).toBeCalledTimes(1); + }); + + test('should be able to select with the up and down arrows', () => { + renderWithContext(); + screen.getByText('Files').click(); + fireEvent.change(screen.getByPlaceholderText('Search files'), {target: {value: 'ext:'}}); + expect(screen.getByText('Text file')).toHaveClass('selected'); + expect(screen.getByText('Word Document')).not.toHaveClass('selected'); + fireEvent.keyDown(screen.getByPlaceholderText('Search files'), {key: 'ArrowDown', code: 'ArrowDown'}); + expect(screen.getByText('Text file')).not.toHaveClass('selected'); + expect(screen.getByText('Word Document')).toHaveClass('selected'); + fireEvent.keyDown(screen.getByPlaceholderText('Search files'), {key: 'ArrowUp', code: 'ArrowUp'}); + expect(screen.getByText('Text file')).toHaveClass('selected'); + expect(screen.getByText('Word Document')).not.toHaveClass('selected'); + }); +}); diff --git a/webapp/channels/src/components/new_search/search_box.tsx b/webapp/channels/src/components/new_search/search_box.tsx new file mode 100644 index 0000000000..b28b874ad5 --- /dev/null +++ b/webapp/channels/src/components/new_search/search_box.tsx @@ -0,0 +1,268 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useRef, forwardRef, useCallback, useEffect} from 'react'; +import {useIntl} from 'react-intl'; +import styled from 'styled-components'; + +import Constants from 'utils/constants'; +import * as Keyboard from 'utils/keyboard'; +import {escapeRegex} from 'utils/text_formatting'; + +import useSearchSuggestions from './hooks'; +import SearchBoxHints from './search_box_hints'; +import SearchInput from './search_box_input'; +import SearchSuggestions from './search_box_suggestions'; +import SearchTypeSelector from './search_box_type_selector'; + +const {KeyCodes} = Constants; + +type Props = { + onClose: () => void; + onSearch: (searchType: string, searchTerms: string) => void; + initialSearchTerms: string; + initialSearchType: string; +}; + +const SearchBoxContainer = styled.div` + padding: 0px; + + .rdp { + margin: 0; + padding: 16px 20px; + + .rdp-months { + justify-content: center; + width: 100%; + } + + .rdp-month { + width: 100%; + } + + .rdp-table { + max-width: none; + width: 100%; + } + + .rdp-tbody .rdp-button { + display: unset; + } + } +`; + +const CloseIcon = styled.button` + position: absolute; + top: 18px; + right: 18px; + z-index: 1; +`; + +const SearchBox = forwardRef( + ( + {onClose, onSearch, initialSearchTerms, initialSearchType}: Props, + ref: React.Ref, + ): JSX.Element => { + const intl = useIntl(); + const [caretPosition, setCaretPosition] = useState(0); + const [searchTerms, setSearchTerms] = useState(initialSearchTerms); + const [searchType, setSearchType] = useState(initialSearchType || 'messages'); + const [selectedOption, setSelectedOption] = useState(-1); + + const inputRef = useRef(null); + + const getCaretPosition = useCallback(() => { + return inputRef.current?.selectionEnd || 0; + }, []); + + const addSearchHint = useCallback((newSearchTerms: string) => { + setSearchTerms(newSearchTerms); + setTimeout(() => { + inputRef.current?.setSelectionRange(newSearchTerms.length, newSearchTerms.length); + setCaretPosition(newSearchTerms.length); + }, 0); + }, []); + + useEffect(() => { + function updateCaretPosition() { + setCaretPosition(inputRef.current?.selectionEnd || 0); + } + + const input = inputRef.current; + + if (input) { + input.addEventListener('change', updateCaretPosition); + input.addEventListener('keypress', updateCaretPosition); + input.addEventListener('keyup', updateCaretPosition); + input.addEventListener('mousedown', updateCaretPosition); + input.addEventListener('touchstart', updateCaretPosition); + input.addEventListener('input', updateCaretPosition); + input.addEventListener('paste', updateCaretPosition); + input.addEventListener('cut', updateCaretPosition); + input.addEventListener('mousemove', updateCaretPosition); + input.addEventListener('select', updateCaretPosition); + input.addEventListener('selectstart', updateCaretPosition); + } + + return () => { + if (input) { + input.removeEventListener('change', updateCaretPosition); + input.removeEventListener('keypress', updateCaretPosition); + input.removeEventListener('mousedown', updateCaretPosition); + input.removeEventListener('keyup', updateCaretPosition); + input.removeEventListener('touchstart', updateCaretPosition); + input.removeEventListener('input', updateCaretPosition); + input.removeEventListener('paste', updateCaretPosition); + input.removeEventListener('cut', updateCaretPosition); + input.removeEventListener('mousemove', updateCaretPosition); + input.removeEventListener('select', updateCaretPosition); + input.removeEventListener('selectstart', updateCaretPosition); + } + }; + }, [inputRef.current]); + + const [providerResults, suggestionsHeader] = useSearchSuggestions( + searchType, + searchTerms, + caretPosition, + getCaretPosition, + setSelectedOption, + ); + + const focus = useCallback((newposition: number) => { + if (inputRef.current) { + inputRef.current.focus(); + setTimeout(() => { + inputRef.current?.setSelectionRange(newposition, newposition); + }, 0); + } + }, []); + + const updateSearchValue = useCallback( + (value: string, matchedPretext: string) => { + const escapedMatchedPretext = escapeRegex(matchedPretext); + const caretPosition = getCaretPosition(); + const extraSpace = caretPosition === searchTerms.length ? ' ' : ''; + setSearchTerms( + searchTerms.slice(0, caretPosition).replace(new RegExp(escapedMatchedPretext + '$'), '') + + value + + extraSpace + + searchTerms.slice(caretPosition), + ); + focus((caretPosition + value.length + 1) - matchedPretext.length); + }, + [searchTerms, setSearchTerms, focus, getCaretPosition], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent): void => { + if (Keyboard.isKeyPressed(e as any, KeyCodes.ESCAPE)) { + e.stopPropagation(); + e.preventDefault(); + if (!providerResults || providerResults?.items.length === 0 || selectedOption === -1) { + onClose(); + } else { + setSelectedOption(-1); + } + } + + if (Keyboard.isKeyPressed(e as any, KeyCodes.DOWN)) { + e.stopPropagation(); + e.preventDefault(); + const totalItems = providerResults?.items.length || 0; + if (selectedOption + 1 < totalItems) { + setSelectedOption(selectedOption + 1); + } + } + + if (Keyboard.isKeyPressed(e as any, KeyCodes.UP)) { + e.stopPropagation(); + e.preventDefault(); + if (selectedOption > 0) { + setSelectedOption(selectedOption - 1); + } + } + + if (Keyboard.isKeyPressed(e as any, KeyCodes.ENTER)) { + e.stopPropagation(); + e.preventDefault(); + if (!providerResults || providerResults?.items.length === 0 || selectedOption === -1) { + onSearch(searchType, searchTerms); + } else { + const matchedPretext = providerResults?.matchedPretext; + const value = providerResults?.terms[selectedOption]; + updateSearchValue(value, matchedPretext); + setSelectedOption(-1); + } + } + }, + [providerResults, onClose, selectedOption, onSearch, searchType, searchTerms, updateSearchValue], + ); + + const closeHandler = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onClose(); + }, + [onClose], + ); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [searchType]); + + return ( + + + + + + + + + + ); + }, +); + +export default SearchBox; diff --git a/webapp/channels/src/components/new_search/search_box_hints.test.tsx b/webapp/channels/src/components/new_search/search_box_hints.test.tsx new file mode 100644 index 0000000000..6d1f23d1ce --- /dev/null +++ b/webapp/channels/src/components/new_search/search_box_hints.test.tsx @@ -0,0 +1,110 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import { + renderWithContext, + screen, + fireEvent, +} from 'tests/react_testing_utils'; + +import SearchBoxHints from './search_box_hints'; + +const TestProviderResultComponent = ({item, term, matchedPretext, isSelection, onClick, onMouseMove}: any) => { + return ( +
onClick(item.username, matchedPretext)} + onMouseMove={() => onMouseMove()} + className={isSelection ? 'selected' : ''} + > + {item.username} + {term} + {matchedPretext} +
+ ); +}; + +const TestPluginProviderComponent = ({searchTerms, onChangeSearch}: any) => { + return ( +
+ {'Plugin suggestion'} + {searchTerms} + onChangeSearch('test', 't')}>{'onChangeSearch'} +
+ ); +}; + +describe('components/new_search/SearchBoxHints', () => { + const baseProps = { + searchType: 'messages', + searchTerms: '', + setSearchTerms: jest.fn(), + focus: jest.fn(), + selectedOption: -1, + providerResults: { + matchedPretext: '', + terms: ['user1', 'user2'], + items: [{username: 'test-username1'}, {username: 'test-username2'}], + component: TestProviderResultComponent, + }, + }; + + test('should show the hints for messages', () => { + renderWithContext(); + expect(screen.getByText('From:')).toBeInTheDocument(); + }); + + test('should change the search term and focus on click', () => { + renderWithContext(); + fireEvent.click(screen.getByText('From:')); + expect(baseProps.setSearchTerms).toHaveBeenCalledWith('From:'); + expect(baseProps.focus).toHaveBeenCalledWith(5); + }); + + test('should set the selected option if it is passed from the parent', () => { + const props = {...baseProps, selectedOption: 1}; + renderWithContext(); + expect(screen.getByText('Press Enter to select')).toBeInTheDocument(); + }); + + test('should not show the plugin suggestions without license', () => { + const props = {...baseProps, searchType: 'test-id', searchTerms: 'test-search-terms'}; + renderWithContext( + , + { + plugins: {components: {SearchHints: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}}, + entities: {general: {license: {IsLicensed: 'false'}}}, + }, + ); + expect(screen.queryByText('Plugin suggestion')).not.toBeInTheDocument(); + expect(screen.queryByText('test-search-terms')).not.toBeInTheDocument(); + }); + + test('should show the plugin suggestions', () => { + const props = {...baseProps, searchType: 'test-id', searchTerms: 'test-search-terms'}; + renderWithContext( + , + { + plugins: {components: {SearchHints: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}}, + entities: {general: {license: {IsLicensed: 'true'}}}, + }, + ); + expect(screen.getByText('Plugin suggestion')).toBeInTheDocument(); + expect(screen.getByText('test-search-terms')).toBeInTheDocument(); + }); + + test('should on search change change the search term and focus', () => { + const props = {...baseProps, searchType: 'test-id', searchTerms: 'something from:t'}; + renderWithContext( + , + { + plugins: {components: {SearchHints: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}}, + entities: {general: {license: {IsLicensed: 'true'}}}, + }, + ); + screen.getByText('onChangeSearch').click(); + expect(baseProps.setSearchTerms).toHaveBeenCalledWith('something from:test '); + expect(baseProps.focus).toHaveBeenCalledWith(20); + }); +}); diff --git a/webapp/channels/src/components/new_search/search_box_hints.tsx b/webapp/channels/src/components/new_search/search_box_hints.tsx new file mode 100644 index 0000000000..fedc8fe258 --- /dev/null +++ b/webapp/channels/src/components/new_search/search_box_hints.tsx @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useSelector} from 'react-redux'; + +import {getSearchBoxHints} from 'selectors/plugins'; + +import type {ProviderResult} from 'components/suggestion/provider'; +import SearchDateSuggestion from 'components/suggestion/search_date_suggestion'; + +import ErrorBoundary from 'plugins/pluggable/error_boundary'; + +import SearchHints from './search_hint'; + +type Props = { + searchTerms: string; + setSearchTerms: (searchTerms: string) => void; + searchType: string; + selectedOption: number; + providerResults: ProviderResult|null; + focus: (pos: number) => void; +} + +const SearchBoxHints = ({searchTerms, setSearchTerms, searchType, providerResults, selectedOption, focus}: Props) => { + const filterSelectedCallback = useCallback((filter: string) => { + if (searchTerms.endsWith(' ') || searchTerms.length === 0) { + setSearchTerms(searchTerms + filter); + focus(searchTerms.length + filter.length); + } else { + setSearchTerms(searchTerms + ' ' + filter); + focus(searchTerms.length + filter.length + 1); + } + }, [searchTerms, setSearchTerms, focus]); + + const searchChangeCallback = useCallback((value: string, matchedPretext: string) => { + const changedValue = value.replace(matchedPretext, ''); + setSearchTerms(searchTerms + changedValue + ' '); + focus(searchTerms.length + changedValue.length + 1); + }, [searchTerms, setSearchTerms, focus]); + + const searchPluginHints = useSelector(getSearchBoxHints); + + if (searchType === '' || searchType === 'messages' || searchType === 'files') { + return ( + 0 && selectedOption !== -1)} + isDate={providerResults?.component === SearchDateSuggestion} + /> + ); + } + + const pluginComponentInfo = searchPluginHints.find(({pluginId}: any) => { + if (searchType === pluginId) { + return true; + } + return false; + }); + + if (!pluginComponentInfo) { + return null; + } + + const Component: any = pluginComponentInfo.component; + + return ( + + + + ); +}; + +export default SearchBoxHints; diff --git a/webapp/channels/src/components/new_search/search_box_input.test.tsx b/webapp/channels/src/components/new_search/search_box_input.test.tsx new file mode 100644 index 0000000000..75ba798d09 --- /dev/null +++ b/webapp/channels/src/components/new_search/search_box_input.test.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import { + renderWithContext, + screen, + fireEvent, +} from 'tests/react_testing_utils'; + +import SearchBoxInput from './search_box_input'; + +describe('components/new_search/SearchBoxInput', () => { + const baseProps = { + searchType: 'messages', + searchTerms: '', + setSearchTerms: jest.fn(), + onKeyDown: jest.fn(), + focus: jest.fn(), + }; + + test('should show the right placeholder for messages', () => { + renderWithContext(); + expect(screen.getByPlaceholderText('Search messages')).toBeInTheDocument(); + }); + + test('should show the right placeholder for files', () => { + const props = {...baseProps, searchType: 'files'}; + renderWithContext(); + expect(screen.getByPlaceholderText('Search files')).toBeInTheDocument(); + }); + + test('should show the right placeholder for plugins', () => { + const props = {...baseProps, searchType: 'plugin-id'}; + renderWithContext(); + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); + }); + + test('should show the right value when the searchTerms are set', () => { + const props = {...baseProps, searchTerms: 'test-value'}; + renderWithContext(); + expect(screen.getByPlaceholderText('Search messages')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Search messages')).toHaveValue('test-value'); + }); + + test('should call on key down when there is a key down event on the input field', () => { + const props = {...baseProps}; + renderWithContext(); + fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Enter'}); + expect(props.onKeyDown).toHaveBeenCalledTimes(1); + }); + + test('should call on key down when there is a key down event on the input field', () => { + const props = {...baseProps}; + renderWithContext(); + fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Enter'}); + expect(props.onKeyDown).toHaveBeenCalledTimes(1); + }); + + test('should update the search term on change', () => { + const props = {...baseProps}; + renderWithContext(); + fireEvent.change(screen.getByPlaceholderText('Search messages'), {target: {value: 'new-value'}}); + expect(props.setSearchTerms).toHaveBeenCalledWith('new-value'); + }); + + test('should clear the terms and focus in the input whenever click the clear button', () => { + const props = {...baseProps, searchTerms: 'test-value'}; + renderWithContext(); + fireEvent.click(screen.getByText('Clear')); + expect(props.setSearchTerms).toHaveBeenCalledWith(''); + expect(props.focus).toHaveBeenCalledWith(0); + }); +}); diff --git a/webapp/channels/src/components/new_search/search_box_input.tsx b/webapp/channels/src/components/new_search/search_box_input.tsx new file mode 100644 index 0000000000..1b0faa6900 --- /dev/null +++ b/webapp/channels/src/components/new_search/search_box_input.tsx @@ -0,0 +1,136 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {forwardRef, useCallback} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch} from 'react-redux'; +import styled from 'styled-components'; + +import { + updateSearchTerms, + updateSearchType, +} from 'actions/views/rhs'; + +import QuickInput from 'components/quick_input'; + +const SearchInputContainer = styled.div` + position: relative; + display: flex; + align-items: center; + i { + color: var(--center-channel-color-56); + display: flex; + align-items: center; + &.icon-close { + postion: absolute; + right: 10px; + } + &.icon-magnify { + position: absolute; + left: 20px; + top: 21px; + font-size: 24px; + } + } + .input-wrapper { + flex-grow: 1; + } + && input { + padding: 20px 100px 20px 58px; + height: auto; + border-radius: 0; + border: none; + border-bottom: var(--border-default); + font-size: 20px; + line-height: 28px; + font-family: Metropolis, sans-serif; + :focus { + border: none; + border-bottom: var(--border-default); + box-shadow: none; + } + ::placeholder { + color: rgba(var(--center-channel-color-rgb), 0.75); + } + } +`; + +const ClearButton = styled.button` + display: flex; + position: absolute; + right: 12px; + background: none; + color: rgba(var(--center-channel-color-rgb), 0.75); + &:hover{ + color: rgba(var(--center-channel-color-rgb), 0.88); + background: rgba(var(--center-channel-color-rgb), 0.08); + } +`; + +type Props = { + searchTerms: string; + searchType: string; + setSearchTerms: (searchTerms: string) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + focus: (newPosition: number) => void; +} + +const SearchInput = ({searchTerms, searchType, setSearchTerms, onKeyDown, focus}: Props, inputRef: React.Ref) => { + const intl = useIntl(); + let searchPlaceholder = intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'}); + + if (searchType === 'messages' || searchType === '') { + searchPlaceholder = intl.formatMessage({id: 'search_bar.search_messages', defaultMessage: 'Search messages'}); + } + if (searchType === 'files') { + searchPlaceholder = intl.formatMessage({id: 'search_bar.search_files', defaultMessage: 'Search files'}); + } + + const dispatch = useDispatch(); + + const inputChangeCallback = useCallback((e: React.ChangeEvent) => { + setSearchTerms(e.target.value); + }, [setSearchTerms]); + + const clearSearch = useCallback(() => { + setSearchTerms(''); + dispatch(updateSearchTerms('')); + dispatch(updateSearchType('messages')); + focus(0); + }, [focus, setSearchTerms]); + + return ( + + + + {searchTerms.length > 0 && ( + + + + + )} + + ); +}; + +export default forwardRef(SearchInput); diff --git a/webapp/channels/src/components/new_search/search_box_suggestions.test.tsx b/webapp/channels/src/components/new_search/search_box_suggestions.test.tsx new file mode 100644 index 0000000000..28e2cc2bf5 --- /dev/null +++ b/webapp/channels/src/components/new_search/search_box_suggestions.test.tsx @@ -0,0 +1,138 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import { + renderWithContext, + screen, + fireEvent, +} from 'tests/react_testing_utils'; + +import SearchBoxSuggestions from './search_box_suggestions'; + +const TestProviderResultComponent = ({item, term, matchedPretext, isSelection, onClick, onMouseMove}: any) => { + return ( +
onClick(item.username, matchedPretext)} + onMouseMove={() => onMouseMove()} + className={isSelection ? 'selected' : ''} + > + {item.username} + {term} + {matchedPretext} +
+ ); +}; + +const TestPluginProviderComponent = ({searchTerms, onChangeSearch, onRunSearch}: any) => { + return ( +
+ {'Plugin suggestion'} + {searchTerms} + onChangeSearch('test', 't')}>{'onChangeSearch'} + onRunSearch(searchTerms)}>{'onRunSearch'} +
+ ); +}; + +describe('components/new_search/SearchBoxSuggestions', () => { + const baseProps = { + searchType: 'messages', + searchTerms: '', + selectedOption: -1, + setSelectedOption: jest.fn(), + suggestionsHeader:

{'Test Header'}

, + providerResults: { + matchedPretext: '', + terms: ['user1', 'user2'], + items: [{username: 'test-username1'}, {username: 'test-username2'}], + component: TestProviderResultComponent, + }, + onSearch: jest.fn(), + onSuggestionSelected: jest.fn(), + }; + + test('should show the suggestions and the suggestion header on messages', () => { + renderWithContext(); + expect(screen.getByText('Test Header')).toBeInTheDocument(); + expect(screen.getByText('test-username1')).toBeInTheDocument(); + expect(screen.getByText('user1')).toBeInTheDocument(); + expect(screen.getByText('test-username2')).toBeInTheDocument(); + expect(screen.getByText('user2')).toBeInTheDocument(); + }); + + test('should call the onSuggestionSelected on click', () => { + renderWithContext(); + fireEvent.click(screen.getByText('test-username1')); + expect(baseProps.onSuggestionSelected).toHaveBeenCalledWith('test-username1', ''); + }); + + test('should call the onSuggestionSelected on click with matchedPretext and previous text', () => { + const props = {...baseProps, searchTerms: 'something from:test-user', providerResults: {...baseProps.providerResults, matchedPretext: 'test-user'}}; + renderWithContext(); + fireEvent.click(screen.getByText('test-username1')); + expect(baseProps.onSuggestionSelected).toHaveBeenCalledWith('test-username1', 'test-user'); + }); + + test('should change the selected option on mousemove', () => { + const props = {...baseProps}; + renderWithContext(); + fireEvent.mouseMove(screen.getByText('test-username2')); + expect(baseProps.setSelectedOption).toHaveBeenCalledWith(1); + fireEvent.mouseMove(screen.getByText('test-username1')); + expect(baseProps.setSelectedOption).toHaveBeenCalledWith(0); + }); + + test('should not show the plugin suggestions without license', () => { + const props = {...baseProps, searchType: 'test-id', searchTerms: 'test-search-terms'}; + renderWithContext( + , + { + plugins: {components: {SearchSuggestions: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}}, + entities: {general: {license: {IsLicensed: 'false'}}}, + }, + ); + expect(screen.queryByText('Plugin suggestion')).not.toBeInTheDocument(); + expect(screen.queryByText('test-search-terms')).not.toBeInTheDocument(); + }); + + test('should show the plugin suggestions', () => { + const props = {...baseProps, searchType: 'test-id', searchTerms: 'test-search-terms'}; + renderWithContext( + , + { + plugins: {components: {SearchSuggestions: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}}, + entities: {general: {license: {IsLicensed: 'true'}}}, + }, + ); + expect(screen.getByText('Plugin suggestion')).toBeInTheDocument(); + expect(screen.getByText('test-search-terms')).toBeInTheDocument(); + }); + + test('should call the onSuggestionSelected on plugin search change', () => { + const props = {...baseProps, searchType: 'test-id', searchTerms: 'something from:t'}; + renderWithContext( + , + { + plugins: {components: {SearchSuggestions: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}}, + entities: {general: {license: {IsLicensed: 'true'}}}, + }, + ); + screen.getByText('onChangeSearch').click(); + expect(baseProps.onSuggestionSelected).toHaveBeenCalledWith('test', 't'); + }); + + test('should run search whenver onRunSearch is executed', () => { + const props = {...baseProps, searchType: 'test-id', searchTerms: 'something from:t'}; + renderWithContext( + , + { + plugins: {components: {SearchSuggestions: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}}, + entities: {general: {license: {IsLicensed: 'true'}}}, + }, + ); + screen.getByText('onRunSearch').click(); + expect(baseProps.onSearch).toHaveBeenCalledWith('test-id', 'something from:t'); + }); +}); diff --git a/webapp/channels/src/components/new_search/search_box_suggestions.tsx b/webapp/channels/src/components/new_search/search_box_suggestions.tsx new file mode 100644 index 0000000000..1c03e29fd2 --- /dev/null +++ b/webapp/channels/src/components/new_search/search_box_suggestions.tsx @@ -0,0 +1,142 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useSelector} from 'react-redux'; +import styled from 'styled-components'; + +import type {UserProfile} from '@mattermost/types/users'; + +import {getSearchPluginSuggestions} from 'selectors/plugins'; + +import type {ProviderResult} from 'components/suggestion/provider'; +import type {SuggestionProps} from 'components/suggestion/suggestion'; + +import ErrorBoundary from 'plugins/pluggable/error_boundary'; + +const SuggestionsHeader = styled.div` + margin-top: 16px; + padding: 8px 24px; + color: rgba(var(--center-channel-color-rgb), 0.56); + font-size: 12px; + line-height: 16px; + font-weight: 600; + text-transform: uppercase; +`; + +const SuggestionsBody = styled.div` + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + padding-bottom: 16px; +`; + +type Props = { + searchType: string; + searchTerms: string; + selectedOption: number; + setSelectedOption: (idx: number) => void; + suggestionsHeader: React.ReactNode; + providerResults: ProviderResult | null; + onSearch: (searchType: string, searchTerms: string) => void; + onSuggestionSelected: (value: string, matchedPretext: string) => void; +} + +const SearchSuggestions = ({searchType, searchTerms, suggestionsHeader, providerResults, selectedOption, setSelectedOption, onSearch, onSuggestionSelected}: Props) => { + const runSearch = useCallback((searchTerms: string) => { + onSearch(searchType, searchTerms); + }, [onSearch, searchType]); + + const searchPluginSuggestions = useSelector(getSearchPluginSuggestions); + + const generateLabel = (item: any) => { + let label = ''; + if (item.username) { + label = item.username; + if ((item.first_name || item.last_name) && item.nickname) { + label += ` ${item.first_name} ${item.last_name} ${item.nickname}`; + } else if (item.nickname) { + label += ` ${item.nickname}`; + } else if (item.first_name || item.last_name) { + label += ` ${item.first_name} ${item.last_name}`; + } + } else if (item.type === 'D' || item.type === 'G') { + label = item.display_name; + } else if (item.type === 'P' || item.type === 'O') { + label = item.name; + } else if (item.emoji) { + label = item.name; + } + + if (label) { + label = label.toLowerCase(); + } + return label; + }; + + if (searchType === '' || searchType === 'messages' || searchType === 'files') { + if (!providerResults) { + return null; + } + + return ( + + {suggestionsHeader} + {providerResults.component && providerResults.items[selectedOption] && ( +
+ {generateLabel(providerResults.items[selectedOption])} +
+ )} + {providerResults.items.map((item, idx) => { + if (!providerResults.component) { + return null; + } + const Component = providerResults.component as React.ComponentType>; + return ( + { + setSelectedOption(idx); + }} + /> + ); + })} +
+ ); + } + + const pluginComponentInfo = searchPluginSuggestions.find(({pluginId}: any) => { + if (searchType === pluginId) { + return true; + } + return false; + }); + + if (!pluginComponentInfo) { + return null; + } + + const Component: any = pluginComponentInfo.component; + + return ( + + + + ); +}; + +export default SearchSuggestions; + diff --git a/webapp/channels/src/components/new_search/search_box_type_selector.test.tsx b/webapp/channels/src/components/new_search/search_box_type_selector.test.tsx new file mode 100644 index 0000000000..02ed5566de --- /dev/null +++ b/webapp/channels/src/components/new_search/search_box_type_selector.test.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import { + renderWithContext, + screen, +} from 'tests/react_testing_utils'; + +import SearchBoxTypeSelector from './search_box_type_selector'; + +describe('components/new_search/SearchBoxTypeSelector', () => { + const baseProps = { + setSearchType: jest.fn(), + searchType: 'messages', + }; + + test('should have the built-in type options', () => { + renderWithContext(); + expect(screen.getByText('Files')).toBeInTheDocument(); + expect(screen.getByText('Messages')).toBeInTheDocument(); + }); + + test('on option clicked should call the setSearchType', () => { + renderWithContext(); + screen.getByText('Messages').click(); + expect(baseProps.setSearchType).toHaveBeenCalledWith('messages'); + }); + + test('should not have the plugin options without license', () => { + renderWithContext( + , + { + plugins: {components: {SearchButtons: [{component: (() =>
{'test'}
) as React.ComponentType, pluginId: 'test-id'}]}}, + entities: {general: {license: {IsLicensed: 'false'}}}, + }, + ); + expect(screen.queryByText('test')).not.toBeInTheDocument(); + }); + + test('should have the plugin options', () => { + renderWithContext( + , + { + plugins: {components: {SearchButtons: [{component: (() =>
{'test'}
) as React.ComponentType, pluginId: 'test-id'}]}}, + entities: {general: {license: {IsLicensed: 'true'}}}, + }, + ); + expect(screen.getByText('test')).toBeInTheDocument(); + }); + + test('on plugin option clicked should call the setSearchType', () => { + renderWithContext( + , + { + plugins: {components: {SearchButtons: [{component: (() =>
{'test'}
) as React.ComponentType, pluginId: 'test-id'}]}}, + entities: {general: {license: {IsLicensed: 'true'}}}, + }, + ); + screen.getByText('test').click(); + expect(baseProps.setSearchType).toHaveBeenCalledWith('test-id'); + }); +}); diff --git a/webapp/channels/src/components/new_search/search_box_type_selector.tsx b/webapp/channels/src/components/new_search/search_box_type_selector.tsx new file mode 100644 index 0000000000..218c81ca64 --- /dev/null +++ b/webapp/channels/src/components/new_search/search_box_type_selector.tsx @@ -0,0 +1,95 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; +import styled from 'styled-components'; + +import {getSearchButtons} from 'selectors/plugins'; + +import ErrorBoundary from 'plugins/pluggable/error_boundary'; + +const SearchTypeSelectorContainer = styled.div` + margin: 20px 20px 0px 20px; + display: flex; + align-items: center; + padding: 3px; + background-color: var(--center-channel-bg); + border-radius: var(--radius-m); + border: var(--border-default); + width: fit-content; + gap: 3px; +`; + +type SearchTypeItemProps = { + selected: boolean; +}; + +const SearchTypeItem = styled.button` + display: flex; + cursor: pointer; + padding: 4px 10px; + background-color: ${(props) => (props.selected ? 'rgba(var(--button-bg-rgb), 0.08)' : 'transparent')}; + color: ${(props) => (props.selected ? 'var(--button-bg)' : 'rgba(var(--center-channel-color-rgb), 0.75)')}; + border-radius: 4px; + font-size: 12px; + line-height: 16px; + font-weight: 600; + border: none; + &:hover { + color: rgba(var(--center-channel-color-rgb), 0.88); + background: rgba(var(--center-channel-color-rgb), 0.08); + } +`; + +type Props = { + searchType: string; + setSearchType: (searchType: string) => void; +} + +const SearchTypeSelector = ({searchType, setSearchType}: Props) => { + const setMessagesSearchType = useCallback(() => setSearchType('messages'), [setSearchType]); + const setFilesSearchType = useCallback(() => setSearchType('files'), [setSearchType]); + + const searchPluginButtons = useSelector(getSearchButtons); + + return ( + + + + + + + + {searchPluginButtons.map(({component, pluginId}: any) => { + const Component = component as React.ComponentType; + return ( + setSearchType(pluginId)} + > + + + + + ); + })} + + ); +}; + +export default SearchTypeSelector; diff --git a/webapp/channels/src/components/new_search/search_hint.test.tsx b/webapp/channels/src/components/new_search/search_hint.test.tsx new file mode 100644 index 0000000000..9ce91ee35a --- /dev/null +++ b/webapp/channels/src/components/new_search/search_hint.test.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import { + renderWithContext, + screen, +} from 'tests/react_testing_utils'; + +import SearchHint from './search_hint'; + +describe('components/new_search/SearchHint', () => { + const baseProps = { + onSelectFilter: jest.fn(), + searchType: 'messages', + searchTerms: '', + hasSelectedOption: false, + isDate: false, + }; + + test('should have the right hint options on search messages empty string', () => { + renderWithContext(); + expect(screen.getByText('From:')).toBeInTheDocument(); + expect(screen.queryByText('Ext:')).not.toBeInTheDocument(); + }); + + test('should suggest to hit enter to search on search messages with not empty string not ended with space', () => { + const props = {...baseProps, searchTerms: 'test'}; + renderWithContext(); + expect(screen.getByText('Press Enter to search')).toBeInTheDocument(); + }); + + test('should have the right hint options on search messages with not empty string ended with space', () => { + const props = {...baseProps, searchTerms: 'test '}; + renderWithContext(); + expect(screen.getByText('From:')).toBeInTheDocument(); + expect(screen.queryByText('Ext:')).not.toBeInTheDocument(); + }); + + test('should have the right hint options on search files empty string', () => { + const props = {...baseProps, searchType: 'files'}; + renderWithContext(); + expect(screen.getByText('From:')).toBeInTheDocument(); + expect(screen.queryByText('Ext:')).toBeInTheDocument(); + }); + + test('should suggest to hit enter to search on search files with not empty string not ended with space', () => { + const props = {...baseProps, searchType: 'files', searchTerms: 'test'}; + renderWithContext(); + expect(screen.getByText('Press Enter to search')).toBeInTheDocument(); + }); + + test('should have the right hint options on search files with not empty string ended with space', () => { + const props = {...baseProps, searchType: 'files', searchTerms: 'test '}; + renderWithContext(); + expect(screen.getByText('From:')).toBeInTheDocument(); + expect(screen.getByText('Ext:')).toBeInTheDocument(); + }); + + test('should be empty on search if is date', () => { + const props = {...baseProps, isDate: true}; + const {asFragment} = renderWithContext(); + expect(asFragment()).toMatchInlineSnapshot(''); + }); + + test('should suggest to hit enter to select on search if has selected option', () => { + const props = {...baseProps, hasSelectedOption: true}; + renderWithContext(); + expect(screen.getByText('Press Enter to select')).toBeInTheDocument(); + }); + + test('on filter clicked should call the onSelectFilter', () => { + renderWithContext(); + screen.getByText('From:').click(); + expect(baseProps.onSelectFilter).toHaveBeenCalledWith('From:'); + }); +}); diff --git a/webapp/channels/src/components/new_search/search_hint.tsx b/webapp/channels/src/components/new_search/search_hint.tsx new file mode 100644 index 0000000000..ff3c2efc7a --- /dev/null +++ b/webapp/channels/src/components/new_search/search_hint.tsx @@ -0,0 +1,101 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import styled from 'styled-components'; + +import {searchHintOptions, searchFilesHintOptions} from 'utils/constants'; + +type Props = { + onSelectFilter: (filter: string) => void; + searchType: string; + searchTerms: string; + hasSelectedOption: boolean; + isDate: boolean; +} + +const SearchHintsContainer = styled.div` + display: flex; + padding: 20px 24px; + color: rgba(var(--center-channel-color-rgb), 0.75); + i { + margin-right: 8px; + color: var(--center-channel-color-56); + } +`; + +const SearchFilter = styled.button` + display: flex; + padding: 4px 10px; + color: var(--center-channel-color); + background: rgba(var(--center-channel-color-rgb), 0.08); + border-radius: var(--radius-l); + border: none; + font-size: 10px; + font-weight: 600; + line-height: 12px; + margin-left: 8px; + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.16); + } +`; + +const SearchHints = ({onSelectFilter, searchType, searchTerms, hasSelectedOption, isDate}: Props): JSX.Element => { + const intl = useIntl(); + let filters = searchHintOptions.filter((filter) => filter.searchTerm !== '-' && filter.searchTerm !== '""'); + if (searchType === 'files') { + filters = searchFilesHintOptions.filter((filter) => filter.searchTerm !== '-' && filter.searchTerm !== '""'); + } + + if (isDate) { + return <>; + } + + if (hasSelectedOption) { + return ( + + + + + ); + } + + if (searchTerms.length > 0 && searchTerms[searchTerms.length - 1] !== ' ') { + return ( + + + + + ); + } + + return ( + + + + {filters.map((filter) => ( + onSelectFilter(filter.searchTerm)} + > + + {filter.searchTerm} + + + ))} + + ); +}; + +export default SearchHints; + diff --git a/webapp/channels/src/components/quick_input/quick_input.tsx b/webapp/channels/src/components/quick_input/quick_input.tsx index 38aec38557..b30b0d7399 100644 --- a/webapp/channels/src/components/quick_input/quick_input.tsx +++ b/webapp/channels/src/components/quick_input/quick_input.tsx @@ -65,6 +65,11 @@ export type Props = { */ onKeyUp?: (event: React.KeyboardEvent) => void; + /** + * Callback to handle the key down of the input + */ + onKeyDown?: (event: React.KeyboardEvent) => void; + /** * When true, and an onClear callback is defined, show an X on the input field even if * the input is empty. @@ -80,6 +85,7 @@ export type Props = { type?: string; id?: string; onInput?: (e?: React.FormEvent) => void; + tabIndex?: number; } // A component that can be used to make controlled inputs that function properly in certain diff --git a/webapp/channels/src/components/search_results/index.tsx b/webapp/channels/src/components/search_results/index.tsx index afabc4ad31..02c85d6a5a 100644 --- a/webapp/channels/src/components/search_results/index.tsx +++ b/webapp/channels/src/components/search_results/index.tsx @@ -15,6 +15,7 @@ import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; import { getSearchResultsTerms, + getSearchResultsType, getIsSearchingTerm, getIsSearchingFlaggedPost, getIsSearchingPinnedPost, @@ -76,7 +77,7 @@ function makeMapStateToProps() { // this is basically a hack to make ts compiler happy // add correct type when it is known what exactly is returned from the function - const currentSearch = getCurrentSearchForCurrentTeam(state) as unknown as Record || {}; + const currentSearch = (getCurrentSearchForCurrentTeam(state) as unknown as Record) || {}; const currentTeamName = getCurrentTeam(state)?.name ?? ''; return { @@ -84,6 +85,7 @@ function makeMapStateToProps() { fileResults: files, matches: getSearchMatches(state), searchTerms: getSearchResultsTerms(state), + searchSelectedType: getSearchResultsType(state), isSearchingTerm: getIsSearchingTerm(state), isSearchingFlaggedPost: getIsSearchingFlaggedPost(state), isSearchingPinnedPost: getIsSearchingPinnedPost(state), diff --git a/webapp/channels/src/components/search_results/search_results.tsx b/webapp/channels/src/components/search_results/search_results.tsx index a2f73318e1..d6ec67e40e 100644 --- a/webapp/channels/src/components/search_results/search_results.tsx +++ b/webapp/channels/src/components/search_results/search_results.tsx @@ -92,6 +92,10 @@ const SearchResults: React.FC = (props: Props): JSX.Element => { scrollbars.current?.scrollToTop(); }, [props.searchTerms]); + useEffect(() => { + setSearchType(props.searchSelectedType); + }, [props.searchSelectedType]); + useEffect(() => { // reset search type when switching views setSearchType(props.searchType); diff --git a/webapp/channels/src/components/search_results/types.ts b/webapp/channels/src/components/search_results/types.ts index 0d5d467f4d..bc11e25f4c 100644 --- a/webapp/channels/src/components/search_results/types.ts +++ b/webapp/channels/src/components/search_results/types.ts @@ -29,24 +29,24 @@ export type OwnProps = { setSearchType: (searchType: SearchType) => void; searchFilterType: SearchFilterType; setSearchFilterType: (filterType: SearchFilterType) => void; -} +}; export type StateProps = { results: Post[]; fileResults: FileInfo[]; matches: Record; searchTerms: string; + searchSelectedType: string; isSearchingTerm: boolean; isSearchingFlaggedPost: boolean; isSearchingPinnedPost: boolean; isSearchGettingMore: boolean; isSearchAtEnd: boolean; isSearchFilesAtEnd: boolean; -} +}; export type IntlProps = { intl: IntlShape; -} +}; export type Props = OwnProps & StateProps & IntlProps; - diff --git a/webapp/channels/src/components/suggestion/search_user_provider.tsx b/webapp/channels/src/components/suggestion/search_user_provider.tsx index 3825dc368b..9d3139a7ea 100644 --- a/webapp/channels/src/components/suggestion/search_user_provider.tsx +++ b/webapp/channels/src/components/suggestion/search_user_provider.tsx @@ -17,7 +17,7 @@ import type {ResultsCallback} from './provider'; import {SuggestionContainer} from './suggestion'; import type {SuggestionProps} from './suggestion'; -const SearchUserSuggestion = React.forwardRef>((props, ref) => { +export const SearchUserSuggestion = React.forwardRef>((props, ref) => { const {item} = props; const username = item.username; diff --git a/webapp/channels/src/components/threading/channel_threads/thread_footer/__snapshots__/thread_footer.test.tsx.snap b/webapp/channels/src/components/threading/channel_threads/thread_footer/__snapshots__/thread_footer.test.tsx.snap index 2851a120ef..53ffced045 100644 --- a/webapp/channels/src/components/threading/channel_threads/thread_footer/__snapshots__/thread_footer.test.tsx.snap +++ b/webapp/channels/src/components/threading/channel_threads/thread_footer/__snapshots__/thread_footer.test.tsx.snap @@ -674,6 +674,7 @@ exports[`components/threading/channel_threads/thread_footer should report total day="numeric" dispatch={[Function]} hourCycle="h12" + timeZone="UTC" units={ Array [ "now", @@ -736,6 +737,7 @@ exports[`components/threading/channel_threads/thread_footer should report total relNearest={1} second="numeric" style="long" + timeZone="UTC" timeZoneName="short" units={ Array [ @@ -1520,6 +1522,7 @@ exports[`components/threading/channel_threads/thread_footer should show unread i day="numeric" dispatch={[Function]} hourCycle="h12" + timeZone="UTC" units={ Array [ "now", @@ -1582,6 +1585,7 @@ exports[`components/threading/channel_threads/thread_footer should show unread i relNearest={1} second="numeric" style="long" + timeZone="UTC" timeZoneName="short" units={ Array [ diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 45463db01a..f45f2f5978 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3742,6 +3742,17 @@ "file_search_result_item.download": "Download", "file_search_result_item.more_actions": "More Actions", "file_search_result_item.open_in_channel": "Open in channel", + "file_type.audio": "Audio", + "file_type.code": "Code file", + "file_type.image": "Image", + "file_type.patch": "Patch file", + "file_type.pdf": "Acrobat", + "file_type.presentation": "Powerpoint Presentation", + "file_type.spreadsheet": "Excel spreadsheet", + "file_type.svg": "Vector graphics", + "file_type.text": "Text file", + "file_type.video": "Video", + "file_type.word": "Word Document", "file_upload.disabled": "File attachments are disabled.", "file_upload.drag_folder": "This attachment cannot be uploaded.", "file_upload.fileAbove": "File above {max}MB could not be uploaded: {filename}", @@ -4810,9 +4821,14 @@ "saveChangesPanel.save": "Save", "saveChangesPanel.saved": "Settings saved", "saveChangesPanel.tryAgain": "Try again", + "search_bar.channels": "Channels", + "search_bar.clear": "Clear", + "search_bar.file_types": "File types", "search_bar.files_tab": "Files", "search_bar.messages_tab": "Messages", "search_bar.search": "Search", + "search_bar.search_files": "Search files", + "search_bar.search_messages": "Search messages", "search_bar.search_types.files": "FILES", "search_bar.search_types.messages": "MESSAGES", "search_bar.searchGroupMembers": "Search group members", @@ -4823,6 +4839,7 @@ "search_bar.usage.title": "Search options", "search_bar.usage.title_files": "File search options", "search_bar.usage.title_messages": "Message search options", + "search_bar.users": "Users", "search_files_list_option.after": "Files after a date", "search_files_list_option.before": "Files before a date", "search_files_list_option.exclude": "Exclude search terms", @@ -4840,6 +4857,9 @@ "search_header.title2": "Recent Mentions", "search_header.title3": "Saved messages", "search_header.title5": "Extra Information", + "search_hint.enter_to_search": "Press Enter to search", + "search_hint.enter_to_select": "Press Enter to select", + "search_hint.filter": "Filter your search with:", "search_item.channelArchived": "Archived", "search_item.direct": "Direct Message (with {username})", "search_item.file_tag.direct_message": "Direct Message", diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/timezone_utils.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/timezone_utils.ts index 2657257a8b..80d927224f 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/timezone_utils.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/timezone_utils.ts @@ -21,9 +21,9 @@ export function getUserCurrentTimezone(userTimezone?: UserTimezone): string { } if (useAutomatic) { - return automaticTimezone; + return automaticTimezone || 'UTC'; } - return manualTimezone; + return manualTimezone || 'UTC'; } export function getTimezoneRegion(timezone: string): string { diff --git a/webapp/channels/src/plugins/registry.ts b/webapp/channels/src/plugins/registry.ts index c54a6a02ba..2419b0b14a 100644 --- a/webapp/channels/src/plugins/registry.ts +++ b/webapp/channels/src/plugins/registry.ts @@ -126,6 +126,25 @@ export default class PluginRegistry { return dispatchPluginComponentAction('PostMessageAttachment', this.id, component); }); + // Register components for search. + // Accepts React components. Returns a unique identifier. + registerSearchComponents = ({buttonComponent, suggestionsComponent, hintsComponent, action}: any) => { + const id = generateId(); + store.dispatch({ + type: ActionTypes.RECEIVED_PLUGIN_COMPONENT, + name: 'SearchButtons', + data: { + id, + pluginId: this.id, + component: buttonComponent, + action, + }, + }); + dispatchPluginComponentAction('SearchSuggestions', this.id, suggestionsComponent, id); + dispatchPluginComponentAction('SearchHints', this.id, hintsComponent, id); + return id; + }; + // Register a component to show as a tooltip when a user hovers on a link in a post. // Accepts a React component. Returns a unique identifier. // The component will be passed the following props: diff --git a/webapp/channels/src/reducers/views/rhs.test.js b/webapp/channels/src/reducers/views/rhs.test.js index a7916f0d31..05e3783262 100644 --- a/webapp/channels/src/reducers/views/rhs.test.js +++ b/webapp/channels/src/reducers/views/rhs.test.js @@ -20,6 +20,7 @@ describe('Reducers.RHS', () => { searchTerms: '', searchType: '', searchResultsTerms: '', + searchResultsType: '', size: 'medium', pluggableId: '', isSearchingFlaggedPost: false, @@ -617,6 +618,7 @@ describe('Reducers.RHS', () => { searchTerms: 'user_id', searchType: '', searchResultsTerms: 'user id', + searchResultsType: '', size: 'medium', pluggableId: 'pluggable_id', isSearchingFlaggedPost: true, diff --git a/webapp/channels/src/reducers/views/rhs.ts b/webapp/channels/src/reducers/views/rhs.ts index 5765c20052..af61a4d861 100644 --- a/webapp/channels/src/reducers/views/rhs.ts +++ b/webapp/channels/src/reducers/views/rhs.ts @@ -255,6 +255,18 @@ function searchResultsTerms(state = '', action: AnyAction) { } } +function searchResultsType(state = '', action: AnyAction) { + switch (action.type) { + case ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TYPE: + return action.searchType; + + case UserTypes.LOGOUT_SUCCESS: + return ''; + default: + return state; + } +} + function isSearchingFlaggedPost(state = false, action: AnyAction) { switch (action.type) { case SearchTypes.SEARCH_FLAGGED_POSTS_REQUEST: @@ -407,6 +419,7 @@ export default combineReducers({ searchTerms, searchType, searchResultsTerms, + searchResultsType, size, pluggableId, isSearchingFlaggedPost, diff --git a/webapp/channels/src/selectors/plugins.ts b/webapp/channels/src/selectors/plugins.ts index edc2eca079..358b572f11 100644 --- a/webapp/channels/src/selectors/plugins.ts +++ b/webapp/channels/src/selectors/plugins.ts @@ -6,6 +6,7 @@ import type {AppBinding} from '@mattermost/types/apps'; import {Preferences} from 'mattermost-redux/constants'; import {createSelector} from 'mattermost-redux/selectors/create_selector'; import {appBarEnabled, getAppBarAppBindings} from 'mattermost-redux/selectors/entities/apps'; +import {getLicense} from 'mattermost-redux/selectors/entities/general'; import {get} from 'mattermost-redux/selectors/entities/preferences'; import {createShallowSelector} from 'mattermost-redux/utils/helpers'; @@ -108,3 +109,39 @@ export function showNewChannelWithBoardPulsatingDot(state: GlobalState): boolean const showPulsatingDot = pulsatingDotState !== '' && JSON.parse(pulsatingDotState)[Preferences.NEW_CHANNEL_WITH_BOARD_TOUR_SHOWED] === false; return showPulsatingDot; } + +export const getSearchPluginSuggestions = createSelector( + 'getSearchPluginSuggestions', + getLicense, + (state: GlobalState) => state.plugins.components.SearchSuggestions, + (license, components = []) => { + if (license.IsLicensed !== 'true') { + return []; + } + return components; + }, +); + +export const getSearchBoxHints = createSelector( + 'getSearchBoxHints', + getLicense, + (state: GlobalState) => state.plugins.components.SearchHints, + (license, components = []) => { + if (license.IsLicensed !== 'true') { + return []; + } + return components; + }, +); + +export const getSearchButtons = createSelector( + 'getSearchButtons', + getLicense, + (state: GlobalState) => state.plugins.components.SearchButtons, + (license, components = []) => { + if (license.IsLicensed !== 'true') { + return []; + } + return components; + }, +); diff --git a/webapp/channels/src/selectors/rhs.ts b/webapp/channels/src/selectors/rhs.ts index 9ffca17e23..5dcedd0528 100644 --- a/webapp/channels/src/selectors/rhs.ts +++ b/webapp/channels/src/selectors/rhs.ts @@ -81,7 +81,12 @@ export const getSelectedPost = createSelector( getRealSelectedPost, getSelectedChannelId, getCurrentUserId, - (selectedPostId: Post['id'], selectedPost: Post, selectedPostChannelId: Channel['id'], currentUserId): Post|FakePost => { + ( + selectedPostId: Post['id'], + selectedPost: Post, + selectedPostChannelId: Channel['id'], + currentUserId, + ): Post | FakePost => { if (selectedPost) { return selectedPost; } @@ -91,7 +96,11 @@ export const getSelectedPost = createSelector( id: selectedPostId, exists: false, type: PostTypes.FAKE_PARENT_DELETED as PostType, - message: localizeMessage({id: 'rhs_thread.rootPostDeletedMessage.body', defaultMessage: 'Part of this thread has been deleted due to a data retention policy. You can no longer reply to this thread.'}), + message: localizeMessage({ + id: 'rhs_thread.rootPostDeletedMessage.body', + defaultMessage: + 'Part of this thread has been deleted due to a data retention policy. You can no longer reply to this thread.', + }), channel_id: selectedPostChannelId, user_id: currentUserId, reply_count: 0, @@ -122,6 +131,10 @@ export function getSearchResultsTerms(state: GlobalState): string { return state.views.rhs.searchResultsTerms; } +export function getSearchResultsType(state: GlobalState): string { + return state.views.rhs.searchResultsType; +} + export function getIsSearchingTerm(state: GlobalState): boolean { return state.entities.search.isSearchingTerm; } @@ -139,10 +152,26 @@ export function getIsSearchGettingMore(state: GlobalState): boolean { } export function makeGetDraft() { - let defaultDraft = {message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId: '', rootId: ''}; + let defaultDraft = { + message: '', + fileInfos: [], + uploadsInProgress: [], + createAt: 0, + updateAt: 0, + channelId: '', + rootId: '', + }; return (state: GlobalState, channelId: string, rootId = ''): PostDraft => { if (defaultDraft.channelId !== channelId || defaultDraft.rootId !== rootId) { - defaultDraft = {message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId, rootId}; + defaultDraft = { + message: '', + fileInfos: [], + uploadsInProgress: [], + createAt: 0, + updateAt: 0, + channelId, + rootId, + }; } const prefix = rootId ? StoragePrefixes.COMMENT_DRAFT : StoragePrefixes.DRAFT; const suffix = rootId || channelId; @@ -170,7 +199,15 @@ export function makeGetDraft() { } export function makeGetChannelDraft() { - const defaultDraft = Object.freeze({message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId: '', rootId: ''}); + const defaultDraft = Object.freeze({ + message: '', + fileInfos: [], + uploadsInProgress: [], + createAt: 0, + updateAt: 0, + channelId: '', + rootId: '', + }); const getDraft = makeGetGlobalItemWithDefault(defaultDraft); return (state: GlobalState, channelId?: string): PostDraft => { @@ -192,7 +229,15 @@ export function makeGetChannelDraft() { } export function getPostDraft(state: GlobalState, prefixId: string, suffixId: string): PostDraft { - const defaultDraft = {message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId: '', rootId: ''}; + const defaultDraft = { + message: '', + fileInfos: [], + uploadsInProgress: [], + createAt: 0, + updateAt: 0, + channelId: '', + rootId: '', + }; if (prefixId === StoragePrefixes.COMMENT_DRAFT) { defaultDraft.rootId = suffixId; diff --git a/webapp/channels/src/types/store/rhs.ts b/webapp/channels/src/types/store/rhs.ts index 4272f5280f..ddae428011 100644 --- a/webapp/channels/src/types/store/rhs.ts +++ b/webapp/channels/src/types/store/rhs.ts @@ -34,6 +34,7 @@ export type RhsViewState = { searchType: SearchType; pluggableId: string; searchResultsTerms: string; + searchResultsType: string; isSearchingFlaggedPost: boolean; isSearchingPinnedPost: boolean; isSidebarOpen: boolean; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 9efe998ad3..0e020733cf 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -197,6 +197,7 @@ export const ActionTypes = keyMirror({ UPDATE_RHS_SEARCH_TERMS: null, UPDATE_RHS_SEARCH_TYPE: null, UPDATE_RHS_SEARCH_RESULTS_TERMS: null, + UPDATE_RHS_SEARCH_RESULTS_TYPE: null, SET_RHS_SIZE: null,