From 3b1eb64e02e79f8443f39fe216cd3a69bdfc242b Mon Sep 17 00:00:00 2001 From: Julien Tant <785518+JulienTant@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:40:46 -0700 Subject: [PATCH] [MM-51201/MM-60406/MM-60404] CrossTeam Search posts and files (#28478) * poc - wip * add search files across teams * eslint * fix existing tests * fix webapp style * fix test * add api doc * change initial state in test * add tests on API * add tests on file info layer * fix file search tags * add rhs reducer test * reset team selected when the RHS is suppressed * change css to reflect UI * fix style * fix doc wording * make getSearchTeam return currentTeamId when value is not set * await is unnecessary * revert boolean check and add test * add comment to getSearchTeam to let dev knows it defaults to currentTeam * remove redundant team check * simplfy test * fix style check --------- Co-authored-by: Caleb Roseland Co-authored-by: Mattermost Build --- api/v4/source/files.yaml | 71 +++++- server/channels/api4/file.go | 15 +- server/channels/api4/file_test.go | 60 ++++- .../store/searchtest/file_info_layer.go | 75 ++++++ .../store/sqlstore/file_info_store.go | 5 +- server/public/model/client4.go | 16 +- server/public/model/feature_flags.go | 3 + webapp/channels/src/actions/views/rhs.test.ts | 21 +- webapp/channels/src/actions/views/rhs.ts | 24 +- .../channels/src/components/search/index.tsx | 12 +- .../channels/src/components/search/search.tsx | 11 + .../channels/src/components/search/types.ts | 3 + .../messages_or_files_selector.test.tsx.snap | 218 ++++++++++-------- .../messages_or_files_selector.scss | 2 +- .../messages_or_files_selector.test.tsx | 74 +++--- .../messages_or_files_selector.tsx | 37 +++ .../search_results/search_results.tsx | 6 + .../src/components/search_results/types.ts | 2 + .../channels/src/reducers/views/rhs.test.js | 17 ++ webapp/channels/src/reducers/views/rhs.ts | 17 ++ webapp/channels/src/selectors/rhs.test.ts | 18 ++ webapp/channels/src/selectors/rhs.ts | 6 + webapp/channels/src/types/store/rhs.ts | 2 + webapp/channels/src/utils/constants.tsx | 1 + webapp/platform/client/src/client4.ts | 7 +- 25 files changed, 573 insertions(+), 150 deletions(-) diff --git a/api/v4/source/files.yaml b/api/v4/source/files.yaml index b4d9b2a1c3..e116ae0a19 100644 --- a/api/v4/source/files.yaml +++ b/api/v4/source/files.yaml @@ -375,7 +375,7 @@ from a user include `from:someusername`, using a user's username. To search in a specific channel include `in:somechannel`, using the channel name (not the display - name). To search for specific extensions included `ext:extension`. + name). To search for specific extensions include `ext:extension`. is_or_search: type: boolean description: Set to true if an Or search should be performed vs an And @@ -411,3 +411,72 @@ $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + "/api/v4/files/search": + post: + tags: + - files + - search + summary: Search files across the teams of the current user + description: > + Search for files in the teams of the current user based on file name, + extention and file content (if file content extraction is enabled and + supported for the files). + + __Minimum server version__: 10.2 + + ##### Permissions + + Must be authenticated and have the `view_team` permission. + operationId: SearchFiles + requestBody: + content: + multipart/form-data: + schema: + type: object + required: + - terms + - is_or_search + properties: + terms: + type: string + description: The search terms as entered by the user. To search for files + from a user include `from:someusername`, using a user's + username. To search in a specific channel include + `in:somechannel`, using the channel name (not the display + name). To search for specific extensions include `ext:extension`. + is_or_search: + type: boolean + description: Set to true if an Or search should be performed vs an And + search. + time_zone_offset: + type: integer + default: 0 + description: Offset from UTC of user timezone for date searches. + include_deleted_channels: + type: boolean + description: Set to true if deleted channels should be included in the + search. (archived channels) + page: + type: integer + default: 0 + description: The page to select. (Only works with Elasticsearch) + per_page: + type: integer + default: 60 + description: The number of posts per page. (Only works with Elasticsearch) + description: The search terms and logic to use in the search. + required: true + responses: + "200": + description: Files list retrieval successful + content: + application/json: + schema: + $ref: "#/components/schemas/FileInfoList" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + diff --git a/server/channels/api4/file.go b/server/channels/api4/file.go index 4122d773eb..3bafe051ef 100644 --- a/server/channels/api4/file.go +++ b/server/channels/api4/file.go @@ -39,7 +39,8 @@ func (api *API) InitFile() { api.BaseRoutes.File.Handle("/preview", api.APISessionRequiredTrustRequester(getFilePreview)).Methods(http.MethodGet) api.BaseRoutes.File.Handle("/info", api.APISessionRequired(getFileInfo)).Methods(http.MethodGet) - api.BaseRoutes.Team.Handle("/files/search", api.APISessionRequiredDisableWhenBusy(searchFiles)).Methods(http.MethodPost) + api.BaseRoutes.Team.Handle("/files/search", api.APISessionRequiredDisableWhenBusy(searchFilesInTeam)).Methods(http.MethodPost) + api.BaseRoutes.Files.Handle("/search", api.APISessionRequiredDisableWhenBusy(searchFilesInAllTeams)).Methods(http.MethodPost) api.BaseRoutes.PublicFile.Handle("", api.APIHandler(getPublicFile)).Methods(http.MethodGet, http.MethodHead) } @@ -736,7 +737,7 @@ func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) { web.WriteFileResponse(info.Name, info.MimeType, info.Size, time.Unix(0, info.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, false, w, r) } -func searchFiles(c *Context, w http.ResponseWriter, r *http.Request) { +func searchFilesInTeam(c *Context, w http.ResponseWriter, r *http.Request) { c.RequireTeamId() if c.Err != nil { return @@ -747,6 +748,14 @@ func searchFiles(c *Context, w http.ResponseWriter, r *http.Request) { return } + searchFiles(c, w, r, c.Params.TeamId) +} + +func searchFilesInAllTeams(c *Context, w http.ResponseWriter, r *http.Request) { + searchFiles(c, w, r, "") +} + +func searchFiles(c *Context, w http.ResponseWriter, r *http.Request, teamID string) { var params model.SearchParameter jsonErr := json.NewDecoder(r.Body).Decode(¶ms) if jsonErr != nil { @@ -787,7 +796,7 @@ func searchFiles(c *Context, w http.ResponseWriter, r *http.Request) { startTime := time.Now() - results, err := c.App.SearchFilesInTeamForUser(c.AppContext, terms, c.AppContext.Session().UserId, c.Params.TeamId, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage) + results, err := c.App.SearchFilesInTeamForUser(c.AppContext, terms, c.AppContext.Session().UserId, teamID, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage) elapsedTime := float64(time.Since(startTime)) / float64(time.Second) metrics := c.App.Metrics() diff --git a/server/channels/api4/file_test.go b/server/channels/api4/file_test.go index 735b81bbaa..8c9dcfcd01 100644 --- a/server/channels/api4/file_test.go +++ b/server/channels/api4/file_test.go @@ -1169,7 +1169,7 @@ func TestGetPublicFile(t *testing.T) { require.Equal(t, http.StatusNotFound, resp.StatusCode, "should've failed to get file after it is deleted") } -func TestSearchFiles(t *testing.T) { +func TestSearchFilesInTeam(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() experimentalViewArchivedChannels := *th.App.Config().TeamSettings.ExperimentalViewArchivedChannels @@ -1318,3 +1318,61 @@ func TestSearchFiles(t *testing.T) { require.Error(t, err) CheckUnauthorizedStatus(t, resp) } + +func TestSearchFilesAcrossTeams(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + experimentalViewArchivedChannels := *th.App.Config().TeamSettings.ExperimentalViewArchivedChannels + defer func() { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.TeamSettings.ExperimentalViewArchivedChannels = &experimentalViewArchivedChannels + }) + }() + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.TeamSettings.ExperimentalViewArchivedChannels = true + }) + data, err := testutils.ReadTestFile("test.png") + require.NoError(t, err) + + th.LoginBasic() + client := th.Client + + var teams [2]*model.Team + var channels [2]*model.Channel + for i := 0; i < 2; i++ { + teams[i] = th.CreateTeam() + channels[i] = th.CreateChannelWithClientAndTeam(th.Client, model.ChannelTypeOpen, teams[i].Id) + + th.LinkUserToTeam(th.BasicUser, teams[i]) + th.AddUserToChannel(th.BasicUser, channels[i]) + + filename := "search for fileInfo" + fileInfo, appErr := th.App.UploadFile(th.Context, data, th.BasicChannel.Id, filename) + require.Nil(t, appErr) + + th.CreatePostInChannelWithFiles(channels[i], fileInfo) + } + + terms := "search" + + // BasicUser should have access to all the files + fileInfos, _, err := client.SearchFilesAcrossTeams(context.Background(), terms, false) + require.NoError(t, err) + require.Len(t, fileInfos.Order, 2, "wrong search") + + // a new user that only belongs to the first team should only get one result + newUser := th.CreateUser() + th.LinkUserToTeam(newUser, teams[0]) + th.AddUserToChannel(newUser, channels[0]) + th.UnlinkUserFromTeam(th.BasicUser, teams[1]) + + _, err = th.Client.Logout(context.Background()) + require.NoError(t, err) + _, _, err = th.Client.Login(context.Background(), newUser.Email, newUser.Password) + require.NoError(t, err) + + fileInfos, _, err = client.SearchFilesAcrossTeams(context.Background(), terms, false) + require.NoError(t, err) + require.Len(t, fileInfos.Order, 1, "wrong search") + require.Equal(t, fileInfos.FileInfos[fileInfos.Order[0]].ChannelId, channels[0].Id, "wrong search") +} diff --git a/server/channels/store/searchtest/file_info_layer.go b/server/channels/store/searchtest/file_info_layer.go index bcc482fc08..a83974e20b 100644 --- a/server/channels/store/searchtest/file_info_layer.go +++ b/server/channels/store/searchtest/file_info_layer.go @@ -198,6 +198,11 @@ var searchFileInfoStoreTests = []searchTest{ Fn: testFileInfoSearchShowChannelBookmarkFiles, Tags: []string{EnginePostgres, EngineMySQL, EngineElasticSearch}, }, + { + Name: "Should search files across teams", + Fn: testFileInfoSearchAcrossTeams, + Tags: []string{EngineAll}, + }, } func TestSearchFileInfoStore(t *testing.T, s store.Store, testEngine *SearchTestEngine) { @@ -1727,3 +1732,73 @@ func testFileInfoSearchShowChannelBookmarkFiles(t *testing.T, th *SearchTestHelp require.Len(t, results.FileInfos, 1) require.Equal(t, "message test@test.com", results.FileInfos[file.Id].Name) } + +func testFileInfoSearchAcrossTeams(t *testing.T, th *SearchTestHelper) { + user1, err := th.createUser("user1", "user1", "user1", "user1") + require.NoError(t, err) + defer th.deleteUser(user1) + + user2, err := th.createUser("user2", "user2", "user2", "user2") + require.NoError(t, err) + defer th.deleteUser(user2) + + team1, err := th.createTeam("team1", "team1", model.TeamOpen) + require.NoError(t, err) + defer th.deleteTeam(team1) + + team2, err := th.createTeam("team2", "team2", model.TeamOpen) + require.NoError(t, err) + defer th.deleteTeam(team2) + + // user1 join both teams, user2 join team1 + err = th.addUserToTeams(user1, []string{team1.Id, team2.Id}) + require.NoError(t, err) + err = th.addUserToTeams(user2, []string{team1.Id}) + require.NoError(t, err) + + channel1, err := th.createChannel(team1.Id, "channel1", "channel1", "", model.ChannelTypeOpen, th.User, false) + require.NoError(t, err) + defer th.deleteChannel(channel1) + + channel2, err := th.createChannel(team2.Id, "channel2", "channel2", "", model.ChannelTypeOpen, th.User, false) + require.NoError(t, err) + defer th.deleteChannel(channel2) + + // user1 joins all channels, user2 joins channel1 + err = th.addUserToChannels(user1, []string{channel1.Id, channel2.Id}) + require.NoError(t, err) + err = th.addUserToChannels(user2, []string{channel1.Id}) + require.NoError(t, err) + + postInChannel1, err := th.createPost(user1.Id, channel1.Id, "message", "", model.PostTypeDefault, 0, false) + require.NoError(t, err) + defer th.deleteUserPosts(user1.Id) + postInChannel2, err := th.createPost(user1.Id, channel2.Id, "message", "", model.PostTypeDefault, 0, false) + require.NoError(t, err) + defer th.deleteUserPosts(user1.Id) + + p1, err := th.createFileInfo(user1.Id, postInChannel1.Id, postInChannel1.ChannelId, "channel test filename", "channel contenttest filename", "jpg", "image/jpeg", 0, 0) + require.NoError(t, err) + defer th.deleteUserFileInfos(th.User.Id) + p2, err := th.createFileInfo(user1.Id, postInChannel2.Id, postInChannel2.ChannelId, "channel test filename", "channel contenttest filename", "jpg", "image/jpeg", 0, 0) + require.NoError(t, err) + defer th.deleteUserFileInfos(th.User.Id) + + t.Run("user in all teams", func(t *testing.T) { + params := &model.SearchParams{Terms: "test"} + results, err := th.Store.FileInfo().Search(th.Context, []*model.SearchParams{params}, user1.Id, "", 0, 20) + require.NoError(t, err) + + require.Len(t, results.FileInfos, 2) + th.checkFileInfoInSearchResults(t, p1.Id, results.FileInfos) + th.checkFileInfoInSearchResults(t, p2.Id, results.FileInfos) + }) + t.Run("user in team1", func(t *testing.T) { + params := &model.SearchParams{Terms: "test"} + results, err := th.Store.FileInfo().Search(th.Context, []*model.SearchParams{params}, user2.Id, "", 0, 20) + require.NoError(t, err) + + require.Len(t, results.FileInfos, 1) + th.checkFileInfoInSearchResults(t, p1.Id, results.FileInfos) + }) +} diff --git a/server/channels/store/sqlstore/file_info_store.go b/server/channels/store/sqlstore/file_info_store.go index 021e11154f..c33ca9a71e 100644 --- a/server/channels/store/sqlstore/file_info_store.go +++ b/server/channels/store/sqlstore/file_info_store.go @@ -519,7 +519,6 @@ func (fs SqlFileInfoStore) Search(rctx request.CTX, paramsList []*model.SearchPa From("FileInfo"). LeftJoin("Channels as C ON C.Id=FileInfo.ChannelId"). LeftJoin("ChannelMembers as CM ON C.Id=CM.ChannelId"). - Where(sq.Or{sq.Eq{"C.TeamId": teamId}, sq.Eq{"C.TeamId": ""}}). Where(sq.Eq{"FileInfo.DeleteAt": 0}). Where(sq.Or{ sq.Eq{"FileInfo.CreatorId": model.BookmarkFileOwner}, @@ -528,6 +527,10 @@ func (fs SqlFileInfoStore) Search(rctx request.CTX, paramsList []*model.SearchPa OrderBy("FileInfo.CreateAt DESC"). Limit(100) + if teamId != "" { + query = query.Where(sq.Or{sq.Eq{"C.TeamId": teamId}, sq.Eq{"C.TeamId": ""}}) + } + for _, params := range paramsList { params.Terms = removeNonAlphaNumericUnquotedTerms(params.Terms, " ") diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 52b0551e2d..eb884d18ff 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -4415,7 +4415,12 @@ func (c *Client4) SearchFilesWithParams(ctx context.Context, teamId string, para if err != nil { return nil, nil, NewAppError("SearchFilesWithParams", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } - r, err := c.DoAPIPost(ctx, c.teamRoute(teamId)+"/files/search", string(js)) + + route := c.teamRoute(teamId) + "/files/search" + if teamId == "" { + route = c.filesRoute() + "/search" + } + r, err := c.DoAPIPost(ctx, route, string(js)) if err != nil { return nil, BuildResponse(r), err } @@ -4428,6 +4433,15 @@ func (c *Client4) SearchFilesWithParams(ctx context.Context, teamId string, para return &list, BuildResponse(r), nil } +// SearchFilesAcrossTeams returns any posts with matching terms string. +func (c *Client4) SearchFilesAcrossTeams(ctx context.Context, terms string, isOrSearch bool) (*FileInfoList, *Response, error) { + params := SearchParameter{ + Terms: &terms, + IsOrSearch: &isOrSearch, + } + return c.SearchFilesWithParams(ctx, "", ¶ms) +} + // SearchPosts returns any posts with matching terms string. func (c *Client4) SearchPosts(ctx context.Context, teamId string, terms string, isOrSearch bool) (*PostList, *Response, error) { params := SearchParameter{ diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index 2a00b2b0c5..93d9001ca7 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -55,6 +55,8 @@ type FeatureFlags struct { NotificationMonitoring bool ExperimentalAuditSettingsSystemConsoleUI bool + + ExperimentalCrossTeamSearch bool } func (f *FeatureFlags) SetDefaults() { @@ -78,6 +80,7 @@ func (f *FeatureFlags) SetDefaults() { f.WebSocketEventScope = true f.NotificationMonitoring = true f.ExperimentalAuditSettingsSystemConsoleUI = false + f.ExperimentalCrossTeamSearch = false } // ToMap returns the feature flags as a map[string]string diff --git a/webapp/channels/src/actions/views/rhs.test.ts b/webapp/channels/src/actions/views/rhs.test.ts index 65ec5a3fb0..cbaa52aa3c 100644 --- a/webapp/channels/src/actions/views/rhs.test.ts +++ b/webapp/channels/src/actions/views/rhs.test.ts @@ -39,6 +39,7 @@ import { goBack, showChannelMembers, openShowEditHistory, + updateSearchTeam, } from 'actions/views/rhs'; import mockStore from 'tests/test_store'; @@ -229,7 +230,7 @@ describe('rhs view actions', () => { test('it dispatches searchPosts correctly', () => { const terms = '@here test search'; - store.dispatch(performSearch(terms, false)); + store.dispatch(performSearch(terms, currentTeamId, false)); const compareStore = mockStore(initialState); compareStore.dispatch(SearchActions.searchPostsWithParams(currentTeamId, {include_deleted_channels: false, terms, is_or_search: false, time_zone_offset: timeZoneOffset, page: 0, per_page: 20})); @@ -251,7 +252,7 @@ describe('rhs view actions', () => { }); const terms = '@here test search'; - store.dispatch(performSearch(terms, false)); + store.dispatch(performSearch(terms, currentTeamId, false)); const filesExtTerms = '@here test search ext:txt ext:jpeg'; const compareStore = mockStore(initialState); @@ -263,12 +264,12 @@ describe('rhs view actions', () => { test('it dispatches searchPosts correctly for Recent Mentions', () => { const terms = `@here test search ${currentUsername} @${currentUsername} ${currentUserFirstName}`; - store.dispatch(performSearch(terms, true)); + store.dispatch(performSearch(terms, '', true)); const mentionsQuotedTerms = `@here test search "${currentUsername}" "@${currentUsername}" "${currentUserFirstName}"`; const compareStore = mockStore(initialState); compareStore.dispatch(SearchActions.searchPostsWithParams('', {include_deleted_channels: false, terms: mentionsQuotedTerms, is_or_search: true, time_zone_offset: timeZoneOffset, page: 0, per_page: 20})); - compareStore.dispatch(SearchActions.searchFilesWithParams(currentTeamId, {include_deleted_channels: false, terms, is_or_search: true, time_zone_offset: timeZoneOffset, page: 0, per_page: 20})); + compareStore.dispatch(SearchActions.searchFilesWithParams('', {include_deleted_channels: false, terms, is_or_search: true, time_zone_offset: timeZoneOffset, page: 0, per_page: 20})); expect(store.getActions()).toEqual(compareStore.getActions()); }); @@ -282,6 +283,7 @@ describe('rhs view actions', () => { views: { rhs: { searchTerms: terms, + searchTeam: null, searchType: 'messages', filesSearchExtFilter: [] as string[], }, @@ -303,7 +305,7 @@ describe('rhs view actions', () => { type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TYPE, searchType: 'messages', }); - compareStore.dispatch(performSearch(terms)); + compareStore.dispatch(performSearch(terms, currentTeamId)); expect(store.getActions()).toEqual(compareStore.getActions()); }); @@ -483,7 +485,7 @@ describe('rhs view actions', () => { const compareStore = mockStore(initialState); - compareStore.dispatch(performSearch('@mattermost ', true)); + compareStore.dispatch(performSearch('@mattermost ', '', true)); compareStore.dispatch(batchActions([ { type: ActionTypes.UPDATE_RHS_SEARCH_TERMS, @@ -740,6 +742,7 @@ describe('rhs view actions', () => { compareStore.dispatch(SearchActions.clearSearch()); compareStore.dispatch(updateSearchTerms('')); + compareStore.dispatch(updateSearchTeam(null)); compareStore.dispatch({ type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TERMS, terms: '', @@ -759,7 +762,7 @@ describe('rhs view actions', () => { store.dispatch(openAtPrevious({isMentionSearch: true})); const compareStore = mockStore(initialState); - compareStore.dispatch(performSearch('@mattermost ', true)); + compareStore.dispatch(performSearch('@mattermost ', '', true)); compareStore.dispatch(batchActions([ { type: ActionTypes.UPDATE_RHS_SEARCH_TERMS, @@ -872,6 +875,7 @@ describe('rhs view actions', () => { views: { rhs: { searchTerms: terms, + searchTeam: null, searchType: 'messages', filesSearchExtFilter: [] as string[], }, @@ -891,7 +895,8 @@ describe('rhs view actions', () => { type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TYPE, searchType: 'messages', }); - compareStore.dispatch(performSearch(terms)); + + compareStore.dispatch(performSearch(terms, currentTeamId)); expect(store.getActions()).toEqual(compareStore.getActions()); }); diff --git a/webapp/channels/src/actions/views/rhs.ts b/webapp/channels/src/actions/views/rhs.ts index 2e3dd31ae9..818352c351 100644 --- a/webapp/channels/src/actions/views/rhs.ts +++ b/webapp/channels/src/actions/views/rhs.ts @@ -33,6 +33,7 @@ import { getPluggableId, getFilesSearchExtFilter, getPreviousRhsState, + getSearchTeam, } from 'selectors/rhs'; import {SidebarSize} from 'components/resizable_sidebar/constants'; @@ -144,6 +145,13 @@ export function updateSearchTerms(terms: string) { }; } +export function updateSearchTeam(teamId: string | null) { + return { + type: ActionTypes.UPDATE_RHS_SEARCH_TEAM, + teamId, + }; +} + export function setRhsSize(rhsSize?: SidebarSize) { let newSidebarSize = rhsSize; if (!newSidebarSize) { @@ -201,10 +209,9 @@ function updateSearchResultsType(searchType: string) { }; } -export function performSearch(terms: string, isMentionSearch?: boolean): ThunkActionFunc { +export function performSearch(terms: string, teamId: string, isMentionSearch?: boolean): ThunkActionFunc { return (dispatch, getState) => { let searchTerms = terms; - const teamId = getCurrentTeamId(getState()); const config = getConfig(getState()); const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; const extensionsFilters = getFilesSearchExtFilter(getState()); @@ -236,7 +243,7 @@ export function performSearch(terms: string, isMentionSearch?: boolean): ThunkAc // timezone offset in seconds const userCurrentTimezone = getCurrentTimezone(getState()); const timezoneOffset = ((userCurrentTimezone && (userCurrentTimezone.length > 0)) ? getUtcOffsetForTimeZone(userCurrentTimezone) : getBrowserUtcOffset()) * 60; - const messagesPromise = dispatch(searchPostsWithParams(isMentionSearch ? '' : teamId, {terms: searchTerms, is_or_search: Boolean(isMentionSearch), include_deleted_channels: viewArchivedChannels, time_zone_offset: timezoneOffset, page: 0, per_page: 20})); + const messagesPromise = dispatch(searchPostsWithParams(teamId, {terms: searchTerms, is_or_search: Boolean(isMentionSearch), include_deleted_channels: viewArchivedChannels, time_zone_offset: timezoneOffset, page: 0, per_page: 20})); const filesPromise = dispatch(searchFilesWithParams(teamId, {terms: termsWithExtensionsFilters, is_or_search: Boolean(isMentionSearch), include_deleted_channels: viewArchivedChannels, time_zone_offset: timezoneOffset, page: 0, per_page: 20})); return Promise.all([filesPromise, messagesPromise]); }; @@ -254,17 +261,19 @@ export function showSearchResults(isMentionSearch = false): ThunkActionFunc { return async (dispatch, getState) => { const state = getState(); - const teamId = getCurrentTeamId(state); + const teamId = getSearchTeam(state); let previousRhsState = getRhsState(state); if (previousRhsState === RHSStates.CHANNEL_FILES) { @@ -420,7 +429,7 @@ export function showChannelFiles(channelId: string): ActionFuncAsync { trackEvent('api', 'api_posts_search_mention'); - dispatch(performSearch(terms, true)); + dispatch(performSearch(terms, '', true)); dispatch(batchActions([ { type: ActionTypes.UPDATE_RHS_SEARCH_TERMS, @@ -604,6 +613,7 @@ export function openRHSSearch(): ActionFunc { return (dispatch) => { dispatch(clearSearch()); dispatch(updateSearchTerms('')); + dispatch(updateSearchTeam(null)); dispatch(updateSearchResultsTerms('')); dispatch(updateRhsState(RHSStates.SEARCH)); diff --git a/webapp/channels/src/components/search/index.tsx b/webapp/channels/src/components/search/index.tsx index 39ebceb086..4d4e579aef 100644 --- a/webapp/channels/src/components/search/index.tsx +++ b/webapp/channels/src/components/search/index.tsx @@ -7,11 +7,13 @@ import type {Dispatch} from 'redux'; import {getMorePostsForSearch, getMoreFilesForSearch} from 'mattermost-redux/actions/search'; import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; import {autocompleteChannelsForSearch} from 'actions/channel_actions'; import {autocompleteUsersInTeam} from 'actions/user_actions'; import { updateSearchTerms, + updateSearchTeam, updateSearchTermsForShortcut, showSearchResults, showChannelFiles, @@ -24,7 +26,7 @@ import { filterFilesSearchByExt, updateSearchType, } from 'actions/views/rhs'; -import {getRhsState, getSearchTerms, getSearchType, getIsSearchingTerm, getIsRhsOpen, getIsRhsExpanded} from 'selectors/rhs'; +import {getRhsState, getSearchTeam, getSearchTerms, getSearchType, getIsSearchingTerm, getIsRhsOpen, getIsRhsExpanded} from 'selectors/rhs'; import {getIsMobileView} from 'selectors/views/browser'; import {RHSStates} from 'utils/constants'; @@ -39,12 +41,18 @@ function mapStateToProps(state: GlobalState) { const isMobileView = getIsMobileView(state); const isRhsOpen = getIsRhsOpen(state); + let searchTeam = getSearchTeam(state); + if (!searchTeam) { + searchTeam = currentChannel?.team_id || ''; + } + return { currentChannel, isRhsExpanded: getIsRhsExpanded(state), isRhsOpen, isSearchingTerm: getIsSearchingTerm(state), searchTerms: getSearchTerms(state), + searchTeam, searchType: getSearchType(state), searchVisible: rhsState !== null && (![ RHSStates.PLUGIN, @@ -58,6 +66,7 @@ function mapStateToProps(state: GlobalState) { isPinnedPosts: rhsState === RHSStates.PIN, isChannelFiles: rhsState === RHSStates.CHANNEL_FILES, isMobileView, + crossTeamSearchEnabled: getFeatureFlagValue(state, 'ExperimentalCrossTeamSearch') === 'true', }; } @@ -65,6 +74,7 @@ function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators({ updateSearchTerms, + updateSearchTeam, updateSearchTermsForShortcut, updateSearchType, showSearchResults, diff --git a/webapp/channels/src/components/search/search.tsx b/webapp/channels/src/components/search/search.tsx index 0e122162c7..1d1ef34b6b 100644 --- a/webapp/channels/src/components/search/search.tsx +++ b/webapp/channels/src/components/search/search.tsx @@ -207,6 +207,14 @@ const Search: React.FC = (props: Props): JSX.Element => { handleUpdateSearchTerms(pretextArray.join(' ')); }; + const handleUpdateSearchTeam = async (teamId: string) => { + actions.updateSearchTeam(teamId); + handleSearch().then(() => { + setKeepInputFocused(false); + setFocused(false); + }); + }; + const handleUpdateSearchTerms = (terms: string): void => { actions.updateSearchTerms(terms); updateHighlightedSearchHint(); @@ -310,6 +318,7 @@ const Search: React.FC = (props: Props): JSX.Element => { actions.updateRhsState(RHSStates.SEARCH); } actions.updateSearchTerms(''); + actions.updateSearchTeam(null); actions.updateSearchType(''); }; @@ -544,6 +553,7 @@ const Search: React.FC = (props: Props): JSX.Element => { channelDisplayName={props.channelDisplayName} isOpened={props.isSideBarRightOpen} updateSearchTerms={handleAddSearchTerm} + updateSearchTeam={handleUpdateSearchTeam} handleSearchHintSelection={handleSearchHintSelection} isSideBarExpanded={props.isRhsExpanded} getMorePostsForSearch={props.actions.getMorePostsForSearch} @@ -552,6 +562,7 @@ const Search: React.FC = (props: Props): JSX.Element => { searchFilterType={searchFilterType} setSearchType={(value: SearchType) => actions.updateSearchType(value)} searchType={searchType || 'messages'} + crossTeamSearchEnabled={props.crossTeamSearchEnabled} /> ) : props.children} diff --git a/webapp/channels/src/components/search/types.ts b/webapp/channels/src/components/search/types.ts index 3c08fe4d5c..c357444309 100644 --- a/webapp/channels/src/components/search/types.ts +++ b/webapp/channels/src/components/search/types.ts @@ -27,6 +27,7 @@ export type StateProps = { isRhsOpen: boolean; isSearchingTerm: boolean; searchTerms: string; + searchTeam: string; searchType: SearchType; searchVisible: boolean; hideMobileSearchBarInRHS: boolean; @@ -36,11 +37,13 @@ export type StateProps = { isChannelFiles: boolean; currentChannel?: Channel; isMobileView: boolean; + crossTeamSearchEnabled: boolean; } export type DispatchProps = { actions: { updateSearchTerms: (term: string) => Action; + updateSearchTeam: (teamId: string|null) => Action; updateSearchTermsForShortcut: () => void; updateSearchType: (searchType: string) => Action; showSearchResults: (isMentionSearch: boolean) => unknown; diff --git a/webapp/channels/src/components/search_results/__snapshots__/messages_or_files_selector.test.tsx.snap b/webapp/channels/src/components/search_results/__snapshots__/messages_or_files_selector.test.tsx.snap index 1f45992c50..de6f25abd6 100644 --- a/webapp/channels/src/components/search_results/__snapshots__/messages_or_files_selector.test.tsx.snap +++ b/webapp/channels/src/components/search_results/__snapshots__/messages_or_files_selector.test.tsx.snap @@ -1,117 +1,139 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`components/search_results/MessagesOrFilesSelector should match snapshot, on files selected 1`] = ` -
-
- - -
- -
+ `; exports[`components/search_results/MessagesOrFilesSelector should match snapshot, on messages selected 1`] = ` -
-
- - -
-
+ + `; exports[`components/search_results/MessagesOrFilesSelector should match snapshot, without files tab 1`] = ` -
-
- -
- -
+ `; diff --git a/webapp/channels/src/components/search_results/messages_or_files_selector.scss b/webapp/channels/src/components/search_results/messages_or_files_selector.scss index 09da3a0b76..7c4b3bb29e 100644 --- a/webapp/channels/src/components/search_results/messages_or_files_selector.scss +++ b/webapp/channels/src/components/search_results/messages_or_files_selector.scss @@ -1,11 +1,11 @@ .MessagesOrFilesSelector { display: flex; align-items: center; - justify-content: space-between; padding: 7px; border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); .buttons-container { + flex-grow: 1; margin: 7px 0; } diff --git a/webapp/channels/src/components/search_results/messages_or_files_selector.test.tsx b/webapp/channels/src/components/search_results/messages_or_files_selector.test.tsx index e01feec0dd..5e8e9a2b3b 100644 --- a/webapp/channels/src/components/search_results/messages_or_files_selector.test.tsx +++ b/webapp/channels/src/components/search_results/messages_or_files_selector.test.tsx @@ -4,21 +4,30 @@ import {shallow} from 'enzyme'; import type {ShallowWrapper} from 'enzyme'; import React from 'react'; +import {Provider} from 'react-redux'; import MessagesOrFilesSelector from 'components/search_results/messages_or_files_selector'; +import mockStore from 'tests/test_store'; + describe('components/search_results/MessagesOrFilesSelector', () => { + const store = mockStore({}); + test('should match snapshot, on messages selected', () => { const wrapper: ShallowWrapper = shallow( - , + + + , ); expect(wrapper).toMatchSnapshot(); @@ -26,30 +35,41 @@ describe('components/search_results/MessagesOrFilesSelector', () => { test('should match snapshot, on files selected', () => { const wrapper: ShallowWrapper = shallow( - , + + + + , ); expect(wrapper).toMatchSnapshot(); }); test('should match snapshot, without files tab', () => { const wrapper: ShallowWrapper = shallow( - , + + + + + , ); expect(wrapper).toMatchSnapshot(); diff --git a/webapp/channels/src/components/search_results/messages_or_files_selector.tsx b/webapp/channels/src/components/search_results/messages_or_files_selector.tsx index 8e401ed740..8c84437432 100644 --- a/webapp/channels/src/components/search_results/messages_or_files_selector.tsx +++ b/webapp/channels/src/components/search_results/messages_or_files_selector.tsx @@ -3,12 +3,18 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {getMyTeams} from 'mattermost-redux/selectors/entities/teams'; + +import {getSearchTeam} from 'selectors/rhs'; import type {SearchFilterType} from 'components/search/types'; import Constants from 'utils/constants'; import * as Keyboard from 'utils/keyboard'; +import type {GlobalState} from 'types/store'; import type {SearchType} from 'types/store/rhs'; import FilesFilterMenu from './files_filter_menu'; @@ -23,11 +29,25 @@ type Props = { messagesCounter: string; filesCounter: string; isFileAttachmentsEnabled: boolean; + crossTeamSearchEnabled: boolean; onChange: (value: SearchType) => void; onFilter: (filter: SearchFilterType) => void; + onTeamChange: (teamId: string) => void; }; export default function MessagesOrFilesSelector(props: Props): JSX.Element { + const teams = useSelector((state: GlobalState) => getMyTeams(state)); + const searchTeam = useSelector((state: GlobalState) => getSearchTeam(state)); + + const options = [{value: '', label: 'All teams', selected: searchTeam === ''}]; + for (const team of teams) { + options.push({value: team.id, label: team.display_name, selected: searchTeam === team.id}); + } + + const onTeamChange = (e: React.ChangeEvent) => { + props.onTeamChange(e.target.value); + }; + return (
@@ -56,6 +76,23 @@ export default function MessagesOrFilesSelector(props: Props): JSX.Element { }
+ {props.crossTeamSearchEnabled && ( +
+ +
+ )} {props.selected === 'files' && = (props: Props): JSX.Element => { } }; + const setSearchTeam = (teamId: string): void => { + props.updateSearchTeam(teamId); + }; + const loadMorePosts = debounce( () => { props.getMorePostsForSearch(); @@ -373,6 +377,8 @@ const SearchResults: React.FC = (props: Props): JSX.Element => { filesCounter={isSearchFilesAtEnd || props.searchPage === 0 ? `${fileResults.length}` : `${fileResults.length}+`} onChange={setSearchType} onFilter={setSearchFilterType} + onTeamChange={setSearchTeam} + crossTeamSearchEnabled={props.crossTeamSearchEnabled} />} {isChannelFiles &&
diff --git a/webapp/channels/src/components/search_results/types.ts b/webapp/channels/src/components/search_results/types.ts index bc11e25f4c..7bf11795ff 100644 --- a/webapp/channels/src/components/search_results/types.ts +++ b/webapp/channels/src/components/search_results/types.ts @@ -29,6 +29,8 @@ export type OwnProps = { setSearchType: (searchType: SearchType) => void; searchFilterType: SearchFilterType; setSearchFilterType: (filterType: SearchFilterType) => void; + updateSearchTeam: (teamId: string) => void; + crossTeamSearchEnabled: boolean; }; export type StateProps = { diff --git a/webapp/channels/src/reducers/views/rhs.test.js b/webapp/channels/src/reducers/views/rhs.test.js index 05e3783262..da147ad3cf 100644 --- a/webapp/channels/src/reducers/views/rhs.test.js +++ b/webapp/channels/src/reducers/views/rhs.test.js @@ -30,6 +30,7 @@ describe('Reducers.RHS', () => { isSidebarExpanded: false, editChannelMembers: false, shouldFocusRHS: false, + searchTeam: null, }; test('Initial state', () => { @@ -282,6 +283,21 @@ describe('Reducers.RHS', () => { }); }); + test('should match searchTeam state', () => { + const nextState = rhsReducer( + {}, + { + type: ActionTypes.UPDATE_RHS_SEARCH_TEAM, + teamId: 'team_id', + }, + ); + + expect(nextState).toEqual({ + ...initialState, + searchTeam: 'team_id', + }); + }); + test('should match select_post state', () => { const nextState1 = rhsReducer( {}, @@ -628,6 +644,7 @@ describe('Reducers.RHS', () => { isSidebarExpanded: true, editChannelMembers: false, shouldFocusRHS: false, + searchTeam: null, }; const nextState = rhsReducer(state, {type: ActionTypes.SUPPRESS_RHS}); diff --git a/webapp/channels/src/reducers/views/rhs.ts b/webapp/channels/src/reducers/views/rhs.ts index af61a4d861..b26970708b 100644 --- a/webapp/channels/src/reducers/views/rhs.ts +++ b/webapp/channels/src/reducers/views/rhs.ts @@ -209,6 +209,22 @@ function searchTerms(state = '', action: AnyAction) { } } +function searchTeam(state = null, action: AnyAction) { + switch (action.type) { + case ActionTypes.UPDATE_RHS_SEARCH_TEAM: + return action.teamId; + case ActionTypes.UPDATE_RHS_STATE: + if (action.state !== RHSStates.SEARCH) { + return null; + } + return state; + case UserTypes.LOGOUT_SUCCESS: + return null; + default: + return state; + } +} + function searchType(state = '', action: AnyAction) { switch (action.type) { case ActionTypes.UPDATE_RHS_SEARCH_TYPE: @@ -417,6 +433,7 @@ export default combineReducers({ filesSearchExtFilter, rhsState, searchTerms, + searchTeam, searchType, searchResultsTerms, searchResultsType, diff --git a/webapp/channels/src/selectors/rhs.test.ts b/webapp/channels/src/selectors/rhs.test.ts index a1e73e9593..159843217a 100644 --- a/webapp/channels/src/selectors/rhs.test.ts +++ b/webapp/channels/src/selectors/rhs.test.ts @@ -65,4 +65,22 @@ describe('Selectors.Rhs', () => { expect(Selectors.getPreviousRhsState(state)).toEqual(previous); }); }); + + describe('should return the search team', () => { + test.each([ + [undefined, 'currentTeamId'], + [null, 'currentTeamId'], + ['', ''], + ['searchTeamId', 'searchTeamId'], + ])('%p gives %p', (searchTeam, expected) => { + const state = { + entities: {teams: {currentTeamId: 'currentTeamId'}}, + views: {rhs: { + searchTeam, + }} as any, + } as GlobalState; + + expect(Selectors.getSearchTeam(state)).toEqual(expected); + }); + }); }); diff --git a/webapp/channels/src/selectors/rhs.ts b/webapp/channels/src/selectors/rhs.ts index 5dcedd0528..66f5a77507 100644 --- a/webapp/channels/src/selectors/rhs.ts +++ b/webapp/channels/src/selectors/rhs.ts @@ -6,6 +6,7 @@ import type {Post, PostType} from '@mattermost/types/posts'; import {createSelector} from 'mattermost-redux/selectors/create_selector'; import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getGlobalItem, makeGetGlobalItem, makeGetGlobalItemWithDefault} from 'selectors/storage'; @@ -123,6 +124,11 @@ export function getSearchTerms(state: GlobalState): string { return state.views.rhs.searchTerms; } +// getSearchTeam returns the team ID that the search is currently scoped to, or the current team if no team was specified. +export function getSearchTeam(state: GlobalState): string { + return state.views.rhs.searchTeam ?? getCurrentTeamId(state); +} + export function getSearchType(state: GlobalState): SearchType { return state.views.rhs.searchType; } diff --git a/webapp/channels/src/types/store/rhs.ts b/webapp/channels/src/types/store/rhs.ts index ddae428011..b215fe10b3 100644 --- a/webapp/channels/src/types/store/rhs.ts +++ b/webapp/channels/src/types/store/rhs.ts @@ -3,6 +3,7 @@ import type {Channel} from '@mattermost/types/channels'; import type {Post, PostType} from '@mattermost/types/posts'; +import type {Team} from '@mattermost/types/teams'; import type {UserProfile} from '@mattermost/types/users'; import type {SidebarSize} from 'components/resizable_sidebar/constants'; @@ -31,6 +32,7 @@ export type RhsViewState = { filesSearchExtFilter: string[]; rhsState: RhsState; searchTerms: string; + searchTeam: Team['id'] | null; searchType: SearchType; pluggableId: string; searchResultsTerms: string; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index b418e9188d..a0b54f0eb7 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -195,6 +195,7 @@ export const ActionTypes = keyMirror({ UPDATE_RHS_STATE: null, UPDATE_RHS_SEARCH_TERMS: null, + UPDATE_RHS_SEARCH_TEAM: null, UPDATE_RHS_SEARCH_TYPE: null, UPDATE_RHS_SEARCH_RESULTS_TERMS: null, UPDATE_RHS_SEARCH_RESULTS_TYPE: null, diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 8025baa483..1ad2e6e49b 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -2473,8 +2473,13 @@ export default class Client4 { searchFilesWithParams = (teamId: string, params: any) => { this.trackEvent('api', 'api_files_search', {team_id: teamId}); + let route = `${this.getFilesRoute()}/search`; + if (teamId) { + route = `${this.getTeamRoute(teamId)}/files/search`; + } + return this.doFetch( - `${this.getTeamRoute(teamId)}/files/search`, + route, {method: 'post', body: JSON.stringify(params)}, ); };