Cross team search (#2829)

* WIP

* Added test

* Added snapshot

* Updated server tests

* Fixed server lint

* Fixed webapp lint

* Updated server tests
This commit is contained in:
Harshil Sharma 2022-04-19 10:27:14 +05:30 committed by GitHub
parent 958fbd28bf
commit 50930a1cd3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 245 additions and 49 deletions

View file

@ -3166,7 +3166,7 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
auditRec.AddMeta("teamID", teamID) auditRec.AddMeta("teamID", teamID)
// retrieve boards list // retrieve boards list
boards, err := a.app.SearchBoardsForUserAndTeam(term, userID, teamID) boards, err := a.app.SearchBoardsForUser(term, userID, teamID)
if err != nil { if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return return

View file

@ -369,8 +369,8 @@ func (a *App) DeleteBoardMember(boardID, userID string) error {
return nil return nil
} }
func (a *App) SearchBoardsForUserAndTeam(term, userID, teamID string) ([]*model.Board, error) { func (a *App) SearchBoardsForUser(term, userID, teamID string) ([]*model.Board, error) {
return a.store.SearchBoardsForUserAndTeam(term, userID, teamID) return a.store.SearchBoardsForUser(term, userID, teamID)
} }
func (a *App) UndeleteBoard(boardID string, modifiedBy string) error { func (a *App) UndeleteBoard(boardID string, modifiedBy string) error {

View file

@ -543,25 +543,25 @@ func TestSearchBoards(t *testing.T) {
Name: "should return all boards where user1 is member or that are public", Name: "should return all boards where user1 is member or that are public",
Client: th.Client, Client: th.Client,
Term: "board", Term: "board",
ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, rBoard3.ID}, ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, rBoard3.ID, board5.ID},
}, },
{ {
Name: "matching a full word", Name: "matching a full word",
Client: th.Client, Client: th.Client,
Term: "admin", Term: "admin",
ExpectedIDs: []string{rBoard1.ID, rBoard3.ID}, ExpectedIDs: []string{rBoard1.ID, rBoard3.ID, board5.ID},
}, },
{ {
Name: "matching part of the word", Name: "matching part of the word",
Client: th.Client, Client: th.Client,
Term: "ubli", Term: "ubli",
ExpectedIDs: []string{rBoard1.ID, rBoard2.ID}, ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, board5.ID},
}, },
{ {
Name: "case insensitive", Name: "case insensitive",
Client: th.Client, Client: th.Client,
Term: "UBLI", Term: "UBLI",
ExpectedIDs: []string{rBoard1.ID, rBoard2.ID}, ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, board5.ID},
}, },
{ {
Name: "user2 can only see the public boards, as he's not a member of any", Name: "user2 can only see the public boards, as he's not a member of any",

View file

@ -1158,19 +1158,19 @@ func (mr *MockStoreMockRecorder) SaveMember(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveMember", reflect.TypeOf((*MockStore)(nil).SaveMember), arg0) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveMember", reflect.TypeOf((*MockStore)(nil).SaveMember), arg0)
} }
// SearchBoardsForUserAndTeam mocks base method. // SearchBoardsForUser mocks base method.
func (m *MockStore) SearchBoardsForUserAndTeam(arg0, arg1, arg2 string) ([]*model.Board, error) { func (m *MockStore) SearchBoardsForUser(arg0, arg1, arg2 string) ([]*model.Board, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SearchBoardsForUserAndTeam", arg0, arg1, arg2) ret := m.ctrl.Call(m, "SearchBoardsForUser", arg0, arg1, arg2)
ret0, _ := ret[0].([]*model.Board) ret0, _ := ret[0].([]*model.Board)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
// SearchBoardsForUserAndTeam indicates an expected call of SearchBoardsForUserAndTeam. // SearchBoardsForUser indicates an expected call of SearchBoardsForUser.
func (mr *MockStoreMockRecorder) SearchBoardsForUserAndTeam(arg0, arg1, arg2 interface{}) *gomock.Call { func (mr *MockStoreMockRecorder) SearchBoardsForUser(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchBoardsForUserAndTeam", reflect.TypeOf((*MockStore)(nil).SearchBoardsForUserAndTeam), arg0, arg1, arg2) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchBoardsForUser", reflect.TypeOf((*MockStore)(nil).SearchBoardsForUser), arg0, arg1, arg2)
} }
// SearchUsersByTeam mocks base method. // SearchUsersByTeam mocks base method.

View file

@ -597,17 +597,16 @@ func (s *SQLStore) getMembersForBoard(db sq.BaseRunner, boardID string) ([]*mode
return s.boardMembersFromRows(rows) return s.boardMembersFromRows(rows)
} }
// searchBoardsForUserAndTeam returns all boards that match with the // searchBoardsForUser returns all boards that match with the
// term that are either private and which the user is a member of, or // term that are either private and which the user is a member of, or
// they're open, regardless of the user membership. // they're open, regardless of the user membership.
// Search is case-insensitive. // Search is case-insensitive.
func (s *SQLStore) searchBoardsForUserAndTeam(db sq.BaseRunner, term, userID, teamID string) ([]*model.Board, error) { func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term, userID string) ([]*model.Board, error) {
query := s.getQueryBuilder(db). query := s.getQueryBuilder(db).
Select(boardFields("b.")...). Select(boardFields("b.")...).
Distinct(). Distinct().
From(s.tablePrefix + "boards as b"). From(s.tablePrefix + "boards as b").
LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id"). LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id").
Where(sq.Eq{"b.team_id": teamID}).
Where(sq.Eq{"b.is_template": false}). Where(sq.Eq{"b.is_template": false}).
Where(sq.Or{ Where(sq.Or{
sq.Eq{"b.type": model.BoardTypeOpen}, sq.Eq{"b.type": model.BoardTypeOpen},
@ -635,7 +634,7 @@ func (s *SQLStore) searchBoardsForUserAndTeam(db sq.BaseRunner, term, userID, te
rows, err := query.Query() rows, err := query.Query()
if err != nil { if err != nil {
s.logger.Error(`searchBoardsForUserAndTeam ERROR`, mlog.Err(err)) s.logger.Error(`searchBoardsForUser ERROR`, mlog.Err(err))
return nil, err return nil, err
} }
defer s.CloseRows(rows) defer s.CloseRows(rows)

View file

@ -677,8 +677,8 @@ func (s *SQLStore) SaveMember(bm *model.BoardMember) (*model.BoardMember, error)
} }
func (s *SQLStore) SearchBoardsForUserAndTeam(term string, userID string, teamID string) ([]*model.Board, error) { func (s *SQLStore) SearchBoardsForUser(term string, userID string, teamID string) ([]*model.Board, error) {
return s.searchBoardsForUserAndTeam(s.db, term, userID, teamID) return s.searchBoardsForUser(s.db, term, userID)
} }

View file

@ -99,7 +99,7 @@ type Store interface {
GetBoardMemberHistory(boardID, userID string, limit uint64) ([]*model.BoardMemberHistoryEntry, error) GetBoardMemberHistory(boardID, userID string, limit uint64) ([]*model.BoardMemberHistoryEntry, error)
GetMembersForBoard(boardID string) ([]*model.BoardMember, error) GetMembersForBoard(boardID string) ([]*model.BoardMember, error)
GetMembersForUser(userID string) ([]*model.BoardMember, error) GetMembersForUser(userID string) ([]*model.BoardMember, error)
SearchBoardsForUserAndTeam(term, userID, teamID string) ([]*model.Board, error) SearchBoardsForUser(term, userID, teamID string) ([]*model.Board, error)
// @withTransaction // @withTransaction
CreateBoardsAndBlocksWithAdmin(bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, []*model.BoardMember, error) CreateBoardsAndBlocksWithAdmin(bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, []*model.BoardMember, error)

View file

@ -68,10 +68,10 @@ func StoreTestBoardStore(t *testing.T, setup func(t *testing.T) (store.Store, fu
defer tearDown() defer tearDown()
testDeleteMember(t, store) testDeleteMember(t, store)
}) })
t.Run("SearchBoardsForUserAndTeam", func(t *testing.T) { t.Run("SearchBoardsForUser", func(t *testing.T) {
store, tearDown := setup(t) store, tearDown := setup(t)
defer tearDown() defer tearDown()
testSearchBoardsForUserAndTeam(t, store) testSearchBoardsForUser(t, store)
}) })
t.Run("GetBoardHistory", func(t *testing.T) { t.Run("GetBoardHistory", func(t *testing.T) {
store, tearDown := setup(t) store, tearDown := setup(t)
@ -683,13 +683,13 @@ func testDeleteMember(t *testing.T, store store.Store) {
}) })
} }
func testSearchBoardsForUserAndTeam(t *testing.T, store store.Store) { func testSearchBoardsForUser(t *testing.T, store store.Store) {
teamID1 := "team-id-1" teamID1 := "team-id-1"
teamID2 := "team-id-2" teamID2 := "team-id-2"
userID := "user-id-1" userID := "user-id-1"
t.Run("should return empty if user is not a member of any board and there are no public boards on the team", func(t *testing.T) { t.Run("should return empty if user is not a member of any board and there are no public boards on the team", func(t *testing.T) {
boards, err := store.SearchBoardsForUserAndTeam("", userID, teamID1) boards, err := store.SearchBoardsForUser("", userID, teamID1)
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, boards) require.Empty(t, boards)
}) })
@ -751,21 +751,21 @@ func testSearchBoardsForUserAndTeam(t *testing.T, store store.Store) {
TeamID: teamID1, TeamID: teamID1,
UserID: userID, UserID: userID,
Term: "", Term: "",
ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID}, ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID, board5.ID},
}, },
{ {
Name: "should find all with term board", Name: "should find all with term board",
TeamID: teamID1, TeamID: teamID1,
UserID: userID, UserID: userID,
Term: "board", Term: "board",
ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID}, ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID, board5.ID},
}, },
{ {
Name: "should find only public as per the term, wether user is a member or not", Name: "should find only public as per the term, wether user is a member or not",
TeamID: teamID1, TeamID: teamID1,
UserID: userID, UserID: userID,
Term: "public", Term: "public",
ExpectedBoardIDs: []string{board1.ID, board2.ID}, ExpectedBoardIDs: []string{board1.ID, board2.ID, board5.ID},
}, },
{ {
Name: "should find only private as per the term, wether user is a member or not", Name: "should find only private as per the term, wether user is a member or not",
@ -774,13 +774,6 @@ func testSearchBoardsForUserAndTeam(t *testing.T, store store.Store) {
Term: "priv", Term: "priv",
ExpectedBoardIDs: []string{board3.ID}, ExpectedBoardIDs: []string{board3.ID},
}, },
{
Name: "should find the only board in team 2",
TeamID: teamID2,
UserID: userID,
Term: "",
ExpectedBoardIDs: []string{board5.ID},
},
{ {
Name: "should find no board in team 2 with a non matching term", Name: "should find no board in team 2 with a non matching term",
TeamID: teamID2, TeamID: teamID2,
@ -792,7 +785,7 @@ func testSearchBoardsForUserAndTeam(t *testing.T, store store.Store) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) { t.Run(tc.Name, func(t *testing.T) {
boards, err := store.SearchBoardsForUserAndTeam(tc.Term, tc.UserID, tc.TeamID) boards, err := store.SearchBoardsForUser(tc.Term, tc.UserID, tc.TeamID)
require.NoError(t, err) require.NoError(t, err)
boardIDs := []string{} boardIDs := []string{}

View file

@ -0,0 +1,95 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`component/BoardSwitcherDialog base case 1`] = `
<div>
<div
class="Dialog dialog-back BoardSwitcherDialog"
>
<div
class="wrapper"
>
<div
class="dialog"
role="dialog"
>
<div
class="toolbar"
>
<button
aria-label="Close dialog"
class="IconButton size--medium"
title="Close dialog"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="BoardSwitcherDialogBody"
>
<div
class="head"
>
<h3
class="text-heading4"
>
Find Boards
</h3>
<h5>
Type to find a board. Use
<b>
UP/DOWN
</b>
to browse.
<b>
ENTER
</b>
to select,
<b>
ESC
</b>
to dismiss
</h5>
<div
class="queryWrapper"
>
<i
class="CompassIcon icon-magnify MagnifyIcon"
/>
<input
class="searchQuery"
maxlength="100"
type="text"
/>
</div>
</div>
<div
class="searchResults"
>
<div
class="noResults introScreen"
>
<div
class="iconWrapper"
>
<i
class="CompassIcon icon-magnify MagnifyIcon"
/>
</div>
<h4
class="text-heading4"
>
Search for boards
</h4>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -3,6 +3,7 @@
gap: 12px; gap: 12px;
overflow: hidden; overflow: hidden;
flex-direction: row; flex-direction: row;
width: 100%;
.CompassIcon { .CompassIcon {
font-size: 18px; font-size: 18px;
@ -20,4 +21,14 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.resultTitle {
max-width: 60%;
}
.teamTitle {
right: auto;
margin-left: auto;
color: rgba(var(--center-channel-color-rgb), 0.56);
}
} }

View file

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {MockStoreEnhanced} from "redux-mock-store"
import {Provider as ReduxProvider} from 'react-redux'
import {render} from "@testing-library/react"
import {createMemoryHistory, History} from "history"
import {Router} from "react-router-dom"
import {Team} from "../../store/teams"
import {TestBlockFactory} from "../../test/testBlockFactory"
import {mockStateStore, wrapDNDIntl} from "../../testUtils"
import BoardSwitcherDialog from "./boardSwitcherDialog"
describe('component/BoardSwitcherDialog', () => {
const team1: Team = {
id: 'team-id-1',
title: 'Dunder Mifflin',
signupToken: '',
updateAt: 0,
modifiedBy: 'michael-scott',
}
const team2: Team = {
id: 'team-id-2',
title: 'Michael Scott Paper Company',
signupToken: '',
updateAt: 0,
modifiedBy: 'michael-scott',
}
const me = TestBlockFactory.createUser()
const state = {
users: {
me: me,
},
teams: {
allTeams: [team1, team2],
current: team1,
}
}
let store:MockStoreEnhanced<unknown, unknown>
let history: History
beforeEach(() => {
store = mockStateStore([], state)
history = createMemoryHistory()
})
test('base case', () => {
const onCloseHandler = jest.fn()
const component = wrapDNDIntl(
<Router history={history}>
<ReduxProvider store={store}>
<BoardSwitcherDialog onClose={onCloseHandler}/>
</ReduxProvider>
</Router>
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
})

View file

@ -12,7 +12,7 @@ import SearchDialog from '../searchDialog/searchDialog'
import Globe from '../../widgets/icons/globe' import Globe from '../../widgets/icons/globe'
import LockOutline from '../../widgets/icons/lockOutline' import LockOutline from '../../widgets/icons/lockOutline'
import {useAppSelector} from '../../store/hooks' import {useAppSelector} from '../../store/hooks'
import {getCurrentTeam} from '../../store/teams' import {getAllTeams, getCurrentTeam, Team} from '../../store/teams'
import {getMe} from '../../store/users' import {getMe} from '../../store/users'
import {BoardTypeOpen, BoardTypePrivate} from '../../blocks/board' import {BoardTypeOpen, BoardTypePrivate} from '../../blocks/board'
@ -38,15 +38,18 @@ const BoardSwitcherDialog = (props: Props): JSX.Element => {
const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string}>() const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string}>()
const history = useHistory() const history = useHistory()
const selectBoard = async (boardId: string): Promise<void> => { const selectBoard = async (teamId: string, boardId: string): Promise<void> => {
if (!me) { if (!me) {
return return
} }
const newPath = generatePath(match.path, {...match.params, boardId, viewId: undefined}) const newPath = generatePath(match.path, {...match.params, teamId, boardId, viewId: undefined})
history.push(newPath) history.push(newPath)
props.onClose() props.onClose()
} }
const teamsById:Record<string, Team> = {}
useAppSelector(getAllTeams).forEach((t) => teamsById[t.id] = t)
const searchHandler = async (query: string): Promise<Array<ReactNode>> => { const searchHandler = async (query: string): Promise<Array<ReactNode>> => {
if (query.trim().length === 0 || !team) { if (query.trim().length === 0 || !team) {
return [] return []
@ -54,17 +57,22 @@ const BoardSwitcherDialog = (props: Props): JSX.Element => {
const items = await octoClient.search(team.id, query) const items = await octoClient.search(team.id, query)
const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled Board'}) const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled Board'})
return items.map((item) => ( return items.map((item) => {
<div const resultTitle = item.title || untitledBoardTitle
key={item.id} const teamTitle = teamsById[item.teamId].title
className='blockSearchResult' return (
onClick={() => selectBoard(item.id)} <div
> key={item.id}
{item.type === BoardTypeOpen && <Globe/>} className='blockSearchResult'
{item.type === BoardTypePrivate && <LockOutline/>} onClick={() => selectBoard(item.teamId, item.id)}
<span>{item.title || untitledBoardTitle}</span> >
</div> {item.type === BoardTypeOpen && <Globe/>}
)) {item.type === BoardTypePrivate && <LockOutline/>}
<span className='resultTitle'>{resultTitle}</span>
<span className='teamTitle'>{teamTitle}</span>
</div>
)
})
} }
return ( return (

View file

@ -82,3 +82,4 @@ export const {reducer} = teamSlice
export const getCurrentTeam = (state: RootState): Team|null => state.teams.current export const getCurrentTeam = (state: RootState): Team|null => state.teams.current
export const getFirstTeam = (state: RootState): Team|null => state.teams.allTeams[0] export const getFirstTeam = (state: RootState): Team|null => state.teams.allTeams[0]
export const getAllTeams = (state: RootState): Array<Team> => state.teams.allTeams

View file

@ -14,6 +14,7 @@ import {Category, CategoryBoards} from '../store/sidebar'
import {Utils} from '../utils' import {Utils} from '../utils'
import {CheckboxBlock, createCheckboxBlock} from '../blocks/checkboxBlock' import {CheckboxBlock, createCheckboxBlock} from '../blocks/checkboxBlock'
import {Block} from '../blocks/block' import {Block} from '../blocks/block'
import {IUser} from "../user"
class TestBlockFactory { class TestBlockFactory {
static createBoard(): Board { static createBoard(): Board {
@ -181,6 +182,18 @@ class TestBlockFactory {
boardIDs: [], boardIDs: [],
} }
} }
static createUser(): IUser {
return {
id: 'user-id-1',
username: 'Dwight Schrute',
email: 'dwight.schrute@dundermifflin.com',
props: {},
create_at: Date.now(),
update_at: Date.now(),
is_bot: false,
}
}
} }
export {TestBlockFactory} export {TestBlockFactory}