Initial version of the new search interface (#27301)

* Initial version of the new search interface

* Fixing CI

* style tweaks

* tweaks to date picker in search

* change search input to button

* Addressing a lot of small problems

* Addressing current search visualization

* Fixing problem on filter autocompletion

* Adding some more fixes

* Tiny style ajustment

* Removing unnecessary slice call

* Adding search extensibility

* Improving the extensibility

* Fixing and refactoring a bit

* On clear click, clear the current search

* splitting components for simplicity

* tweaks to file ext search

* Addressing PR review comments and adding color to the extensions search icons

* Improving a bit the extension search

* Fixing CI

* Fixing some bugs

* Keep the focus on search input whenever we change the search type

* Adding SearchHint tests

* Adding search_box_type_selector tests

* Adding tests for the SearchBox component

* Adding tests for search box suggestions

* Adding SearchBoxInput tests

* Splitting extension suggestion component and provider

* ExtensionSuggestion tests

* Removing unneeded property

* Adding SearchBoxhints tests

* Adding tests for NewSearch component

* Adding tests for the extension suggestion provider

* Fixing linter errors

* Fixing tests

* Fixing linter errors

* Fixing linter errors

* Adding license check for plugins

* Addressing PR review comments

* Addressing PR review comments

* Addressing PR review comments

* Fixing tests

* Fixing some cypress tests

* More work around cypress tests for search

* Some accessibility changes

* more work on cypress tests

* fixing more cypress tests

* Fixing linter errors

* Fixing tests

* Fixing ctrl+f6 switching

* Fixing scrolling on small windows wheneve you are searching

* Improve editing in the middle of the search text

* Addressing some PR review comments

* Addressing some PR review comments

* Addressing some PR review comments

* Addressing some PR review comments

* Fixing tests and linter errors

* Fixingls tests and linter errors

* Fix type checks

* Clearer logic to render plugin or builtin suggestion

* Addressing PR review changes

* Addressing PR review changes

* Using selectors for plugin components

* Changing uppercase to lowercase on lists of components

* Updating tests

* Updating snapshots

* minor css tweaks

* minor css tweak

* Adding the search type and the clear button

* Fixing problem with the filetype selector

* fixing linter errors

* Some fixes

* More linter fixes

* Reverting prettier auto-formating

* Fixing CI

* Fixing CI

* Fixing chrome problem

* Fixing CI

* Addressing PR review comments

---------

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
Co-authored-by: Christopher Speller <crspeller@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Jesús Espino
2024-10-14 12:56:35 +02:00
коммит произвёл GitHub
родитель b7ccd745c8
Коммит 5f8bdba459
71 изменённых файлов: 2503 добавлений и 291 удалений

Просмотреть файл

@@ -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(() => {

Просмотреть файл

@@ -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(() => {

Просмотреть файл

@@ -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');

Просмотреть файл

@@ -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');
});
});

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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);

Просмотреть файл

@@ -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');

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -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');
});

Просмотреть файл

@@ -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');
});

Просмотреть файл

@@ -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}');

Просмотреть файл

@@ -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');

Просмотреть файл

@@ -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');

Просмотреть файл

@@ -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()}`;

Просмотреть файл

@@ -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');

Просмотреть файл

@@ -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();

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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(() => {

Просмотреть файл

@@ -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}');

Просмотреть файл

@@ -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();

Просмотреть файл

@@ -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}');
});
});

Просмотреть файл

@@ -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});

Просмотреть файл

@@ -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');
};

Просмотреть файл

@@ -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) => {

Просмотреть файл

@@ -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');
});
});

Просмотреть файл

@@ -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();

Просмотреть файл

@@ -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();

Просмотреть файл

@@ -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) => {

Просмотреть файл

@@ -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');
});
});

Просмотреть файл

@@ -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');

Просмотреть файл

@@ -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', '');
});
});

Просмотреть файл

@@ -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');

Просмотреть файл

@@ -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},
]);

Просмотреть файл

@@ -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'},
]);

Просмотреть файл

@@ -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());

Просмотреть файл

@@ -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<unknown, GlobalState> {
return (dispatch, getState) => {
let searchTerms = terms;
@@ -240,6 +254,7 @@ export function showSearchResults(isMentionSearch = false): ThunkActionFunc<unkn
const state = getState();
const searchTerms = getSearchTerms(state);
const searchType = getSearchType(state);
if (isMentionSearch) {
dispatch(updateRhsState(RHSStates.MENTION));
@@ -247,6 +262,7 @@ export function showSearchResults(isMentionSearch = false): ThunkActionFunc<unkn
dispatch(updateRhsState(RHSStates.SEARCH));
}
dispatch(updateSearchResultsTerms(searchTerms));
dispatch(updateSearchResultsType(searchType));
return dispatch(performSearch(searchTerms));
};

Просмотреть файл

@@ -9,7 +9,7 @@ import Flex from '@mattermost/compass-components/utilities/layout/Flex'; // esli
import {closeRightHandSide, showMentions} from 'actions/views/rhs';
import {getRhsState} from 'selectors/rhs';
import Search from 'components/search';
import NewSearch from 'components/new_search/new_search';
import {
Constants,
@@ -50,9 +50,7 @@ const GlobalSearchNav = (): JSX.Element => {
flex={1}
alignment='center'
>
<Search
enableFindShortcut={true}
/>
<NewSearch/>
</Flex>
);
};

Просмотреть файл

@@ -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(<ExtensionSuggestion {...baseProps}/>);
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(<ExtensionSuggestion {...props}/>);
expect(screen.getByText('Acrobat')).toBeInTheDocument();
expect(screen.getByText('(.pdf)')).toBeInTheDocument();
});
test('should pass the right data on click', () => {
const props = {...baseProps};
renderWithContext(<ExtensionSuggestion {...props}/>);
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(<ExtensionSuggestion {...props}/>);
expect(screen.getByText('test-type')).toHaveClass('selected');
});
});

Просмотреть файл

@@ -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<string, MessageDescriptor> =
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<ExtensionItem>
>(({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 (
<SearchFileExtensionSuggestionContainer
ref={ref}
className={classNames({selected: isSelection})}
onClick={optionClicked}
>
<div className={classNames('file-icon', getCompassIconClassName(item.type))}/>
{labelName}
<ExtensionText>{`(.${item.value})`}</ExtensionText>
</SearchFileExtensionSuggestionContainer>
);
});
SearchFileExtensionSuggestion.displayName = 'SearchFileExtensionSuggestion';
export default SearchFileExtensionSuggestion;

Просмотреть файл

@@ -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'],
});
});
});

Просмотреть файл

@@ -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<ExtensionItem>) {
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';
}
}

Просмотреть файл

@@ -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<unknown>|null, React.ReactNode] => {
const dispatch = useDispatch();
const [providerResults, setProviderResults] = useState<ProviderResult<unknown>|null>(null);
const [suggestionsHeader, setSuggestionsHeader] = useState<React.ReactNode>(<span/>);
const suggestionProviders = useRef<Provider[]>([
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<React.ReactNode[]>(() => [
<span key={1}/>,
<FormattedMessage
id='search_bar.channels'
defaultMessage='Channels'
key={2}
/>,
<FormattedMessage
id='search_bar.users'
defaultMessage='Users'
key={3}
/>,
<FormattedMessage
id='search_bar.file_types'
defaultMessage='File types'
key={4}
/>,
], []);
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<unknown>) => {
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;

Просмотреть файл

@@ -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(<NewSearch/>);
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(<NewSearch/>);
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(<div><NewSearch/>{'Outside'}</div>);
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(<NewSearch/>);
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(<NewSearch/>);
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(<div><NewSearch/>{'Outside'}</div>);
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();
});
});

Просмотреть файл

@@ -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<boolean>(false);
const [currentChannel, setCurrentChannel] = useState('');
const searchBoxRef = useRef<HTMLDivElement | null>(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 (
<NewSearchContainer
tabIndex={0}
onKeyDown={openSearchBoxOnKeyPress}
onClick={openSearchBox}
id='searchFormContainer'
role='search'
className='a11y__region'
>
<i className='icon icon-magnify'/>
{(searchType === 'messages' || searchType === 'files') && (
<SearchTypeBadge>
{searchType === 'messages' && (
<FormattedMessage
id='search_bar.search_types.messages'
defaultMessage='MESSAGES'
/>
)}
{searchType === 'files' && (
<FormattedMessage
id='search_bar.search_types.files'
defaultMessage='FILES'
/>
)}
<i
className='icon icon-close icon-12'
onClick={clearSearchType}
/>
</SearchTypeBadge>
)}
{searchTerms && <span tabIndex={0}>{searchTerms}</span>}
{searchTerms && (
<CloseIcon
data-testid='input-clear'
role='button'
onClick={onClose}
>
<span
className='input-clear-x'
aria-hidden='true'
>
<i className='icon icon-close-circle'/>
</span>
</CloseIcon>
)}
{!searchTerms && (
<FormattedMessage
id='search_bar.search'
defaultMessage='Search'
/>
)}
{focused && (
<PopoverStyled
id='searchPopover'
placement='bottom'
>
<SearchBox
ref={searchBoxRef}
onClose={closeSearchBox}
onSearch={runSearch}
initialSearchTerms={currentChannel ? `in:${currentChannel} ` : searchTerms}
initialSearchType={searchType}
/>
</PopoverStyled>
)}
</NewSearchContainer>
);
};
export default NewSearch;

Просмотреть файл

@@ -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(<SearchBox {...baseProps}/>);
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(<SearchBox {...props}/>);
expect(screen.getByPlaceholderText('Search messages')).toHaveValue('test');
});
test('should have the focus on the input field after switching search type', () => {
renderWithContext(<SearchBox {...baseProps}/>);
screen.getByText('Files').click();
expect(screen.getByPlaceholderText('Search files')).toHaveFocus();
});
test('should see files hints when i click on files', () => {
renderWithContext(<SearchBox {...baseProps}/>);
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(<SearchBox {...baseProps}/>);
fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Escape', code: 'Escape'});
expect(baseProps.onClose).toBeCalledTimes(1);
});
test('should call search on enter keydown', () => {
renderWithContext(<SearchBox {...baseProps}/>);
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(<SearchBox {...baseProps}/>);
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');
});
});

Просмотреть файл

@@ -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<HTMLDivElement>,
): JSX.Element => {
const intl = useIntl();
const [caretPosition, setCaretPosition] = useState<number>(0);
const [searchTerms, setSearchTerms] = useState<string>(initialSearchTerms);
const [searchType, setSearchType] = useState<string>(initialSearchType || 'messages');
const [selectedOption, setSelectedOption] = useState<number>(-1);
const inputRef = useRef<HTMLInputElement | null>(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<Element>): 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 (
<SearchBoxContainer
ref={ref}
id='searchBox'
aria-label={intl.formatMessage({
id: 'search_bar.search',
defaultMessage: 'Search',
})}
aria-describedby='searchHints'
role='searchbox'
>
<CloseIcon
className='btn btn-icon btn-m'
onClick={closeHandler}
>
<i className='icon icon-close'/>
</CloseIcon>
<SearchTypeSelector
searchType={searchType}
setSearchType={setSearchType}
/>
<SearchInput
ref={inputRef}
searchTerms={searchTerms}
searchType={searchType}
setSearchTerms={setSearchTerms}
onKeyDown={handleKeyDown}
focus={focus}
/>
<SearchSuggestions
searchType={searchType}
searchTerms={searchTerms}
suggestionsHeader={suggestionsHeader}
providerResults={providerResults}
selectedOption={selectedOption}
setSelectedOption={setSelectedOption}
onSearch={onSearch}
onSuggestionSelected={updateSearchValue}
/>
<SearchBoxHints
searchTerms={searchTerms}
setSearchTerms={addSearchHint}
searchType={searchType}
providerResults={providerResults}
selectedOption={selectedOption}
focus={focus}
/>
</SearchBoxContainer>
);
},
);
export default SearchBox;

Просмотреть файл

@@ -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 (
<div
onClick={() => onClick(item.username, matchedPretext)}
onMouseMove={() => onMouseMove()}
className={isSelection ? 'selected' : ''}
>
<span>{item.username}</span>
<span>{term}</span>
<span>{matchedPretext}</span>
</div>
);
};
const TestPluginProviderComponent = ({searchTerms, onChangeSearch}: any) => {
return (
<div>
<span>{'Plugin suggestion'}</span>
<span>{searchTerms}</span>
<span onClick={() => onChangeSearch('test', 't')}>{'onChangeSearch'}</span>
</div>
);
};
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(<SearchBoxHints {...baseProps}/>);
expect(screen.getByText('From:')).toBeInTheDocument();
});
test('should change the search term and focus on click', () => {
renderWithContext(<SearchBoxHints {...baseProps}/>);
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(<SearchBoxHints {...props}/>);
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(
<SearchBoxHints {...props}/>,
{
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(
<SearchBoxHints {...props}/>,
{
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(
<SearchBoxHints {...props}/>,
{
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);
});
});

Просмотреть файл

@@ -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<unknown>|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 (
<SearchHints
onSelectFilter={filterSelectedCallback}
searchType={searchType}
searchTerms={searchTerms}
hasSelectedOption={Boolean(providerResults && providerResults.items.length > 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 (
<ErrorBoundary>
<Component
key={pluginComponentInfo.pluginId}
onChangeSearch={searchChangeCallback}
searchTerms={searchTerms}
/>
</ErrorBoundary>
);
};
export default SearchBoxHints;

Просмотреть файл

@@ -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(<SearchBoxInput {...baseProps}/>);
expect(screen.getByPlaceholderText('Search messages')).toBeInTheDocument();
});
test('should show the right placeholder for files', () => {
const props = {...baseProps, searchType: 'files'};
renderWithContext(<SearchBoxInput {...props}/>);
expect(screen.getByPlaceholderText('Search files')).toBeInTheDocument();
});
test('should show the right placeholder for plugins', () => {
const props = {...baseProps, searchType: 'plugin-id'};
renderWithContext(<SearchBoxInput {...props}/>);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});
test('should show the right value when the searchTerms are set', () => {
const props = {...baseProps, searchTerms: 'test-value'};
renderWithContext(<SearchBoxInput {...props}/>);
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(<SearchBoxInput {...props}/>);
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(<SearchBoxInput {...props}/>);
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(<SearchBoxInput {...props}/>);
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(<SearchBoxInput {...props}/>);
fireEvent.click(screen.getByText('Clear'));
expect(props.setSearchTerms).toHaveBeenCalledWith('');
expect(props.focus).toHaveBeenCalledWith(0);
});
});

Просмотреть файл

@@ -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<Element>) => void;
focus: (newPosition: number) => void;
}
const SearchInput = ({searchTerms, searchType, setSearchTerms, onKeyDown, focus}: Props, inputRef: React.Ref<HTMLInputElement>) => {
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<HTMLInputElement>) => {
setSearchTerms(e.target.value);
}, [setSearchTerms]);
const clearSearch = useCallback(() => {
setSearchTerms('');
dispatch(updateSearchTerms(''));
dispatch(updateSearchType('messages'));
focus(0);
}, [focus, setSearchTerms]);
return (
<SearchInputContainer>
<i className='icon icon-magnify'/>
<QuickInput
ref={inputRef}
className={'search-bar form-control'}
aria-describedby={'searchbar-help-popup'}
aria-label={searchPlaceholder}
placeholder={searchPlaceholder}
value={searchTerms}
onChange={inputChangeCallback}
type='search'
delayInputUpdate={true}
clearable={true}
autoFocus={true}
onKeyDown={onKeyDown}
tabIndex={0}
/>
{searchTerms.length > 0 && (
<ClearButton
className='btn btn-sm input-clear-x'
onClick={clearSearch}
>
<i className='icon icon-close-circle'/>
<FormattedMessage
id='search_bar.clear'
defaultMessage='Clear'
/>
</ClearButton>
)}
</SearchInputContainer>
);
};
export default forwardRef<HTMLInputElement, Props>(SearchInput);

Просмотреть файл

@@ -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 (
<div
onClick={() => onClick(item.username, matchedPretext)}
onMouseMove={() => onMouseMove()}
className={isSelection ? 'selected' : ''}
>
<span>{item.username}</span>
<span>{term}</span>
<span>{matchedPretext}</span>
</div>
);
};
const TestPluginProviderComponent = ({searchTerms, onChangeSearch, onRunSearch}: any) => {
return (
<div>
<span>{'Plugin suggestion'}</span>
<span>{searchTerms}</span>
<span onClick={() => onChangeSearch('test', 't')}>{'onChangeSearch'}</span>
<span onClick={() => onRunSearch(searchTerms)}>{'onRunSearch'}</span>
</div>
);
};
describe('components/new_search/SearchBoxSuggestions', () => {
const baseProps = {
searchType: 'messages',
searchTerms: '',
selectedOption: -1,
setSelectedOption: jest.fn(),
suggestionsHeader: <p>{'Test Header'}</p>,
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(<SearchBoxSuggestions {...baseProps}/>);
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(<SearchBoxSuggestions {...baseProps}/>);
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(<SearchBoxSuggestions {...props}/>);
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(<SearchBoxSuggestions {...props}/>);
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(
<SearchBoxSuggestions {...props}/>,
{
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(
<SearchBoxSuggestions {...props}/>,
{
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(
<SearchBoxSuggestions {...props}/>,
{
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(
<SearchBoxSuggestions {...props}/>,
{
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');
});
});

Просмотреть файл

@@ -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<unknown> | 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 (
<SuggestionsBody>
<SuggestionsHeader>{suggestionsHeader}</SuggestionsHeader>
{providerResults.component && providerResults.items[selectedOption] && (
<div
aria-live='polite'
role='alert'
className='sr-only'
key={providerResults.terms[selectedOption]}
>
{generateLabel(providerResults.items[selectedOption])}
</div>
)}
{providerResults.items.map((item, idx) => {
if (!providerResults.component) {
return null;
}
const Component = providerResults.component as React.ComponentType<SuggestionProps<any>>;
return (
<Component
key={providerResults.terms[idx]}
item={item as UserProfile}
term={providerResults.terms[idx]}
matchedPretext={providerResults.matchedPretext}
isSelection={idx === selectedOption}
onClick={onSuggestionSelected}
onMouseMove={() => {
setSelectedOption(idx);
}}
/>
);
})}
</SuggestionsBody>
);
}
const pluginComponentInfo = searchPluginSuggestions.find(({pluginId}: any) => {
if (searchType === pluginId) {
return true;
}
return false;
});
if (!pluginComponentInfo) {
return null;
}
const Component: any = pluginComponentInfo.component;
return (
<ErrorBoundary>
<Component
key={pluginComponentInfo.pluginId}
searchTerms={searchTerms}
onChangeSearch={onSuggestionSelected}
onRunSearch={runSearch}
/>
</ErrorBoundary>
);
};
export default SearchSuggestions;

Просмотреть файл

@@ -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(<SearchBoxTypeSelector {...baseProps}/>);
expect(screen.getByText('Files')).toBeInTheDocument();
expect(screen.getByText('Messages')).toBeInTheDocument();
});
test('on option clicked should call the setSearchType', () => {
renderWithContext(<SearchBoxTypeSelector {...baseProps}/>);
screen.getByText('Messages').click();
expect(baseProps.setSearchType).toHaveBeenCalledWith('messages');
});
test('should not have the plugin options without license', () => {
renderWithContext(
<SearchBoxTypeSelector {...baseProps}/>,
{
plugins: {components: {SearchButtons: [{component: (() => <pre>{'test'}</pre>) as React.ComponentType, pluginId: 'test-id'}]}},
entities: {general: {license: {IsLicensed: 'false'}}},
},
);
expect(screen.queryByText('test')).not.toBeInTheDocument();
});
test('should have the plugin options', () => {
renderWithContext(
<SearchBoxTypeSelector {...baseProps}/>,
{
plugins: {components: {SearchButtons: [{component: (() => <pre>{'test'}</pre>) 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(
<SearchBoxTypeSelector {...baseProps}/>,
{
plugins: {components: {SearchButtons: [{component: (() => <pre>{'test'}</pre>) as React.ComponentType, pluginId: 'test-id'}]}},
entities: {general: {license: {IsLicensed: 'true'}}},
},
);
screen.getByText('test').click();
expect(baseProps.setSearchType).toHaveBeenCalledWith('test-id');
});
});

Просмотреть файл

@@ -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<SearchTypeItemProps>`
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 (
<SearchTypeSelectorContainer>
<SearchTypeItem
selected={searchType === 'messages'}
onClick={setMessagesSearchType}
>
<FormattedMessage
id='search_bar.usage.search_type_messages'
defaultMessage='Messages'
/>
</SearchTypeItem>
<SearchTypeItem
selected={searchType === 'files'}
onClick={setFilesSearchType}
>
<FormattedMessage
id='search_bar.usage.search_type_files'
defaultMessage='Files'
/>
</SearchTypeItem>
{searchPluginButtons.map(({component, pluginId}: any) => {
const Component = component as React.ComponentType;
return (
<SearchTypeItem
key={pluginId}
selected={searchType === pluginId}
onClick={() => setSearchType(pluginId)}
>
<ErrorBoundary>
<Component/>
</ErrorBoundary>
</SearchTypeItem>
);
})}
</SearchTypeSelectorContainer>
);
};
export default SearchTypeSelector;

Просмотреть файл

@@ -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(<SearchHint {...baseProps}/>);
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(<SearchHint {...props}/>);
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(<SearchHint {...props}/>);
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(<SearchHint {...props}/>);
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(<SearchHint {...props}/>);
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(<SearchHint {...props}/>);
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(<SearchHint {...props}/>);
expect(asFragment()).toMatchInlineSnapshot('<DocumentFragment />');
});
test('should suggest to hit enter to select on search if has selected option', () => {
const props = {...baseProps, hasSelectedOption: true};
renderWithContext(<SearchHint {...props}/>);
expect(screen.getByText('Press Enter to select')).toBeInTheDocument();
});
test('on filter clicked should call the onSelectFilter', () => {
renderWithContext(<SearchHint {...baseProps}/>);
screen.getByText('From:').click();
expect(baseProps.onSelectFilter).toHaveBeenCalledWith('From:');
});
});

Просмотреть файл

@@ -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 (
<SearchHintsContainer id='searchHints'>
<i className='icon icon-keyboard-return'/>
<FormattedMessage
id='search_hint.enter_to_select'
defaultMessage='Press Enter to select'
/>
</SearchHintsContainer>
);
}
if (searchTerms.length > 0 && searchTerms[searchTerms.length - 1] !== ' ') {
return (
<SearchHintsContainer id='searchHints'>
<i className='icon icon-keyboard-return'/>
<FormattedMessage
id='search_hint.enter_to_search'
defaultMessage='Press Enter to search'
/>
</SearchHintsContainer>
);
}
return (
<SearchHintsContainer id='searchHints'>
<i className='icon icon-lightbulb-outline'/>
<FormattedMessage
id='search_hint.filter'
defaultMessage='Filter your search with:'
/>
{filters.map((filter) => (
<SearchFilter
key={filter.searchTerm}
onClick={() => onSelectFilter(filter.searchTerm)}
>
<span title={intl.formatMessage(filter.message)}>
{filter.searchTerm}
</span>
</SearchFilter>
))}
</SearchHintsContainer>
);
};
export default SearchHints;

Просмотреть файл

@@ -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<HTMLInputElement>) => void;
tabIndex?: number;
}
// A component that can be used to make controlled inputs that function properly in certain

Просмотреть файл

@@ -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<string, any> || {};
const currentSearch = (getCurrentSearchForCurrentTeam(state) as unknown as Record<string, any>) || {};
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),

Просмотреть файл

@@ -92,6 +92,10 @@ const SearchResults: React.FC<Props> = (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);

Просмотреть файл

@@ -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<string, string[]>;
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;

Просмотреть файл

@@ -17,7 +17,7 @@ import type {ResultsCallback} from './provider';
import {SuggestionContainer} from './suggestion';
import type {SuggestionProps} from './suggestion';
const SearchUserSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<UserProfile>>((props, ref) => {
export const SearchUserSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<UserProfile>>((props, ref) => {
const {item} = props;
const username = item.username;

Просмотреть файл

@@ -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 [

Просмотреть файл

@@ -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",

Просмотреть файл

@@ -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 {

Просмотреть файл

@@ -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:

Просмотреть файл

@@ -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,

Просмотреть файл

@@ -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,

Просмотреть файл

@@ -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;
},
);

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -34,6 +34,7 @@ export type RhsViewState = {
searchType: SearchType;
pluggableId: string;
searchResultsTerms: string;
searchResultsType: string;
isSearchingFlaggedPost: boolean;
isSearchingPinnedPost: boolean;
isSidebarOpen: boolean;

Просмотреть файл

@@ -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,