1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-01-26 18:48:15 +02:00

Search boards by prop (#4291)

* SearchBoardsForUser API with property name search.
This commit is contained in:
Doug Lauder 2022-12-16 11:46:00 -05:00 committed by GitHub
parent 2a5c033fa5
commit b63542fb63
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 149 additions and 50 deletions

View File

@ -114,6 +114,11 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
// description: The search term. Must have at least one character // description: The search term. Must have at least one character
// required: true // required: true
// type: string // type: string
// - name: field
// in: query
// description: The field to search on for search term. Can be `title`, `property_name`. Defaults to `title`
// required: false
// type: string
// security: // security:
// - BearerAuth: [] // - BearerAuth: []
// responses: // responses:
@ -128,8 +133,18 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
// schema: // schema:
// "$ref": "#/definitions/ErrorResponse" // "$ref": "#/definitions/ErrorResponse"
var err error
teamID := mux.Vars(r)["teamID"] teamID := mux.Vars(r)["teamID"]
term := r.URL.Query().Get("q") term := r.URL.Query().Get("q")
searchFieldText := r.URL.Query().Get("field")
searchField := model.BoardSearchFieldTitle
if searchFieldText != "" {
searchField, err = model.BoardSearchFieldFromString(searchFieldText)
if err != nil {
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
return
}
}
userID := getUserID(r) userID := getUserID(r)
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
@ -153,7 +168,7 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
} }
// retrieve boards list // retrieve boards list
boards, err := a.app.SearchBoardsForUser(term, userID, !isGuest) boards, err := a.app.SearchBoardsForUser(term, searchField, userID, !isGuest)
if err != nil { if err != nil {
a.errorResponse(w, r, err) a.errorResponse(w, r, err)
return return
@ -312,7 +327,7 @@ func (a *API) handleSearchAllBoards(w http.ResponseWriter, r *http.Request) {
} }
// retrieve boards list // retrieve boards list
boards, err := a.app.SearchBoardsForUser(term, userID, !isGuest) boards, err := a.app.SearchBoardsForUser(term, model.BoardSearchFieldTitle, userID, !isGuest)
if err != nil { if err != nil {
a.errorResponse(w, r, err) a.errorResponse(w, r, err)
return return

View File

@ -637,8 +637,8 @@ func (a *App) DeleteBoardMember(boardID, userID string) error {
return nil return nil
} }
func (a *App) SearchBoardsForUser(term, userID string, includePublicBoards bool) ([]*model.Board, error) { func (a *App) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
return a.store.SearchBoardsForUser(term, userID, includePublicBoards) return a.store.SearchBoardsForUser(term, searchField, userID, includePublicBoards)
} }
func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) { func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) {

View File

@ -720,6 +720,17 @@ func (c *Client) GetBoardsForTeam(teamID string) ([]*model.Board, *Response) {
return model.BoardsFromJSON(r.Body), BuildResponse(r) return model.BoardsFromJSON(r.Body), BuildResponse(r)
} }
func (c *Client) SearchBoardsForUser(teamID, term string, field model.BoardSearchField) ([]*model.Board, *Response) {
query := fmt.Sprintf("q=%s&field=%s", term, field)
r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards/search?"+query, "")
if err != nil {
return nil, BuildErrorResponse(r, err)
}
defer closeBody(r)
return model.BoardsFromJSON(r.Body), BuildResponse(r)
}
func (c *Client) SearchBoardsForTeam(teamID, term string) ([]*model.Board, *Response) { func (c *Client) SearchBoardsForTeam(teamID, term string) ([]*model.Board, *Response) {
r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards/search?q="+term, "") r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards/search?q="+term, "")
if err != nil { if err != nil {

View File

@ -271,8 +271,8 @@ func (s *PluginTestStore) GetChannel(teamID, channel string) (*mmModel.Channel,
return nil, errTestStore return nil, errTestStore
} }
func (s *PluginTestStore) SearchBoardsForUser(term string, userID string, includePublicBoards bool) ([]*model.Board, error) { func (s *PluginTestStore) SearchBoardsForUser(term string, field model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
boards, err := s.Store.SearchBoardsForUser(term, userID, includePublicBoards) boards, err := s.Store.SearchBoardsForUser(term, field, userID, includePublicBoards)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -8,6 +8,7 @@ import (
type BoardType string type BoardType string
type BoardRole string type BoardRole string
type BoardSearchField string
const ( const (
BoardTypeOpen BoardType = "O" BoardTypeOpen BoardType = "O"
@ -22,6 +23,12 @@ const (
BoardRoleAdmin BoardRole = "admin" BoardRoleAdmin BoardRole = "admin"
) )
const (
BoardSearchFieldNone BoardSearchField = ""
BoardSearchFieldTitle BoardSearchField = "title"
BoardSearchFieldPropertyName BoardSearchField = "property_name"
)
// Board groups a set of blocks and its layout // Board groups a set of blocks and its layout
// swagger:model // swagger:model
type Board struct { type Board struct {
@ -392,3 +399,13 @@ type BoardMemberHistoryEntry struct {
// required: true // required: true
InsertAt time.Time `json:"insertAt"` InsertAt time.Time `json:"insertAt"`
} }
func BoardSearchFieldFromString(field string) (BoardSearchField, error) {
switch field {
case string(BoardSearchFieldTitle):
return BoardSearchFieldTitle, nil
case string(BoardSearchFieldPropertyName):
return BoardSearchFieldPropertyName, nil
}
return BoardSearchFieldNone, ErrInvalidBoardSearchField
}

View File

@ -24,6 +24,8 @@ var (
ErrBoardMemberIsLastAdmin = errors.New("cannot leave a board with no admins") ErrBoardMemberIsLastAdmin = errors.New("cannot leave a board with no admins")
ErrRequestEntityTooLarge = errors.New("request entity too large") ErrRequestEntityTooLarge = errors.New("request entity too large")
ErrInvalidBoardSearchField = errors.New("invalid board search field")
) )
// ErrNotFound is an error type that can be returned by store APIs // ErrNotFound is an error type that can be returned by store APIs

View File

@ -670,7 +670,7 @@ func (s *MattermostAuthLayer) baseUserQuery(showEmail, showName bool) sq.SelectB
// 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 *MattermostAuthLayer) SearchBoardsForUser(term, userID string, includePublicBoards bool) ([]*model.Board, error) { func (s *MattermostAuthLayer) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
// as we're joining three queries, we need to avoid numbered // as we're joining three queries, we need to avoid numbered
// placeholders until the join is done, so we use the default // placeholders until the join is done, so we use the default
// question mark placeholder here // question mark placeholder here
@ -706,14 +706,29 @@ func (s *MattermostAuthLayer) SearchBoardsForUser(term, userID string, includePu
}) })
if term != "" { if term != "" {
if searchField == model.BoardSearchFieldPropertyName {
var where, whereTerm string
switch s.dbType {
case model.PostgresDBType:
where = "b.properties->? is not null"
whereTerm = term
case model.MysqlDBType, model.SqliteDBType:
where = "JSON_EXTRACT(b.properties, ?) IS NOT NULL"
whereTerm = "$." + term
default:
where = "b.properties LIKE ?"
whereTerm = "%\"" + term + "\"%"
}
boardMembersQ = boardMembersQ.Where(where, whereTerm)
teamMembersQ = teamMembersQ.Where(where, whereTerm)
channelMembersQ = channelMembersQ.Where(where, whereTerm)
} else { // model.BoardSearchFieldTitle
// break search query into space separated words // break search query into space separated words
// and search for all words. // and search for all words.
// This should later be upgraded to industrial-strength // This should later be upgraded to industrial-strength
// word tokenizer, that uses much more than space // word tokenizer, that uses much more than space
// to break words. // to break words.
conditions := sq.And{} conditions := sq.And{}
for _, word := range strings.Split(strings.TrimSpace(term), " ") { for _, word := range strings.Split(strings.TrimSpace(term), " ") {
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"}) conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
} }
@ -722,6 +737,7 @@ func (s *MattermostAuthLayer) SearchBoardsForUser(term, userID string, includePu
teamMembersQ = teamMembersQ.Where(conditions) teamMembersQ = teamMembersQ.Where(conditions)
channelMembersQ = channelMembersQ.Where(conditions) channelMembersQ = channelMembersQ.Where(conditions)
} }
}
teamMembersSQL, teamMembersArgs, err := teamMembersQ.ToSql() teamMembersSQL, teamMembersArgs, err := teamMembersQ.ToSql()
if err != nil { if err != nil {

View File

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

View File

@ -660,7 +660,7 @@ func (s *SQLStore) getMembersForBoard(db sq.BaseRunner, boardID string) ([]*mode
// 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) searchBoardsForUser(db sq.BaseRunner, term, userID string, includePublicBoards bool) ([]*model.Board, error) { func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
query := s.getQueryBuilder(db). query := s.getQueryBuilder(db).
Select(boardFields("b.")...). Select(boardFields("b.")...).
Distinct(). Distinct().
@ -680,20 +680,31 @@ func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term, userID string, in
} }
if term != "" { if term != "" {
if searchField == model.BoardSearchFieldPropertyName {
switch s.dbType {
case model.PostgresDBType:
where := "b.properties->? is not null"
query = query.Where(where, term)
case model.MysqlDBType, model.SqliteDBType:
where := "JSON_EXTRACT(b.properties, ?) IS NOT NULL"
query = query.Where(where, "$."+term)
default:
where := "b.properties LIKE ?"
query = query.Where(where, "%\""+term+"\"%")
}
} else { // model.BoardSearchFieldTitle
// break search query into space separated words // break search query into space separated words
// and search for all words. // and search for all words.
// This should later be upgraded to industrial-strength // This should later be upgraded to industrial-strength
// word tokenizer, that uses much more than space // word tokenizer, that uses much more than space
// to break words. // to break words.
conditions := sq.And{} conditions := sq.And{}
for _, word := range strings.Split(strings.TrimSpace(term), " ") { for _, word := range strings.Split(strings.TrimSpace(term), " ") {
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"}) conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
} }
query = query.Where(conditions) query = query.Where(conditions)
} }
}
rows, err := query.Query() rows, err := query.Query()
if err != nil { if err != nil {

View File

@ -825,8 +825,8 @@ func (s *SQLStore) SaveMember(bm *model.BoardMember) (*model.BoardMember, error)
} }
func (s *SQLStore) SearchBoardsForUser(term string, userID string, includePublicBoards bool) ([]*model.Board, error) { func (s *SQLStore) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
return s.searchBoardsForUser(s.db, term, userID, includePublicBoards) return s.searchBoardsForUser(s.db, term, searchField, userID, includePublicBoards)
} }

View File

@ -104,7 +104,7 @@ type Store interface {
GetMembersForBoard(boardID string) ([]*model.BoardMember, error) GetMembersForBoard(boardID string) ([]*model.BoardMember, error)
GetMembersForUser(userID string) ([]*model.BoardMember, error) GetMembersForUser(userID string) ([]*model.BoardMember, error)
CanSeeUser(seerID string, seenID string) (bool, error) CanSeeUser(seerID string, seenID string) (bool, error)
SearchBoardsForUser(term, userID string, includePublicBoards bool) ([]*model.Board, error) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error)
SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error)
// @withTransaction // @withTransaction

View File

@ -796,7 +796,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
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.SearchBoardsForUser("", userID, true) boards, err := store.SearchBoardsForUser("", model.BoardSearchFieldTitle, userID, true)
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, boards) require.Empty(t, boards)
}) })
@ -806,6 +806,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
TeamID: teamID1, TeamID: teamID1,
Type: model.BoardTypeOpen, Type: model.BoardTypeOpen,
Title: "Public Board with admin", Title: "Public Board with admin",
Properties: map[string]any{"foo": "bar1"},
} }
_, _, err := store.InsertBoardWithAdmin(board1, userID) _, _, err := store.InsertBoardWithAdmin(board1, userID)
require.NoError(t, err) require.NoError(t, err)
@ -815,6 +816,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
TeamID: teamID1, TeamID: teamID1,
Type: model.BoardTypeOpen, Type: model.BoardTypeOpen,
Title: "Public Board", Title: "Public Board",
Properties: map[string]any{"foo": "bar2"},
} }
_, err = store.InsertBoard(board2, userID) _, err = store.InsertBoard(board2, userID)
require.NoError(t, err) require.NoError(t, err)
@ -851,6 +853,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
TeamID string TeamID string
UserID string UserID string
Term string Term string
SearchField model.BoardSearchField
IncludePublic bool IncludePublic bool
ExpectedBoardIDs []string ExpectedBoardIDs []string
}{ }{
@ -859,6 +862,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
TeamID: teamID1, TeamID: teamID1,
UserID: userID, UserID: userID,
Term: "", Term: "",
SearchField: model.BoardSearchFieldTitle,
IncludePublic: true, IncludePublic: true,
ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID, board5.ID}, ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID, board5.ID},
}, },
@ -867,6 +871,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
TeamID: teamID1, TeamID: teamID1,
UserID: userID, UserID: userID,
Term: "board", Term: "board",
SearchField: model.BoardSearchFieldTitle,
IncludePublic: true, IncludePublic: true,
ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID, board5.ID}, ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID, board5.ID},
}, },
@ -875,6 +880,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
TeamID: teamID1, TeamID: teamID1,
UserID: userID, UserID: userID,
Term: "board", Term: "board",
SearchField: model.BoardSearchFieldTitle,
IncludePublic: false, IncludePublic: false,
ExpectedBoardIDs: []string{board1.ID, board3.ID, board5.ID}, ExpectedBoardIDs: []string{board1.ID, board3.ID, board5.ID},
}, },
@ -883,6 +889,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
TeamID: teamID1, TeamID: teamID1,
UserID: userID, UserID: userID,
Term: "public", Term: "public",
SearchField: model.BoardSearchFieldTitle,
IncludePublic: true, IncludePublic: true,
ExpectedBoardIDs: []string{board1.ID, board2.ID, board5.ID}, ExpectedBoardIDs: []string{board1.ID, board2.ID, board5.ID},
}, },
@ -891,6 +898,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
TeamID: teamID1, TeamID: teamID1,
UserID: userID, UserID: userID,
Term: "priv", Term: "priv",
SearchField: model.BoardSearchFieldTitle,
IncludePublic: true, IncludePublic: true,
ExpectedBoardIDs: []string{board3.ID}, ExpectedBoardIDs: []string{board3.ID},
}, },
@ -899,6 +907,25 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
TeamID: teamID2, TeamID: teamID2,
UserID: userID, UserID: userID,
Term: "non-matching-term", Term: "non-matching-term",
SearchField: model.BoardSearchFieldTitle,
IncludePublic: true,
ExpectedBoardIDs: []string{},
},
{
Name: "should find all boards with a named property",
TeamID: teamID1,
UserID: userID,
Term: "foo",
SearchField: model.BoardSearchFieldPropertyName,
IncludePublic: true,
ExpectedBoardIDs: []string{board1.ID, board2.ID},
},
{
Name: "should find no boards with a non-existing named property",
TeamID: teamID1,
UserID: userID,
Term: "bogus",
SearchField: model.BoardSearchFieldPropertyName,
IncludePublic: true, IncludePublic: true,
ExpectedBoardIDs: []string{}, ExpectedBoardIDs: []string{},
}, },
@ -906,7 +933,7 @@ func testSearchBoardsForUser(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.SearchBoardsForUser(tc.Term, tc.UserID, tc.IncludePublic) boards, err := store.SearchBoardsForUser(tc.Term, tc.SearchField, tc.UserID, tc.IncludePublic)
require.NoError(t, err) require.NoError(t, err)
boardIDs := []string{} boardIDs := []string{}