PLT-4403 Add server-based channel autocomplete, search and paging (#4585)

* Add more channel paging API

* Add channel paging support to client

* Add DB channel search functions

* Add API for searching more channels

* Add more channel search functionality to client

* Add API for autocompleting channels

* Add channel autocomplete functionality to the client

* Move to be deprecated APIs to their own file

* Final clean-up

* Fixes related to feedback

* Localization changes

* Add unit as suffix to timeout constants
Этот коммит содержится в:
Joram Wilander
2016-11-24 09:35:09 -05:00
коммит произвёл Harrison Healey
родитель c96ecae6da
Коммит 981ea33b8e
27 изменённых файлов: 1337 добавлений и 381 удалений

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

@@ -103,6 +103,7 @@ func InitApi() {
InitEmoji()
InitStatus()
InitWebrtc()
InitDeprecated()
// 404 on any api route before web.go has a chance to serve it
Srv.Router.Handle("/api/{anything:.*}", http.HandlerFunc(Handle404))

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

@@ -6,6 +6,7 @@ package api
import (
"fmt"
"net/http"
"strconv"
"strings"
l4g "github.com/alecthomas/log4go"
@@ -19,7 +20,8 @@ func InitChannel() {
l4g.Debug(utils.T("api.channel.init.debug"))
BaseRoutes.Channels.Handle("/", ApiUserRequired(getChannels)).Methods("GET")
BaseRoutes.Channels.Handle("/more", ApiUserRequired(getMoreChannels)).Methods("GET")
BaseRoutes.Channels.Handle("/more/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getMoreChannelsPage)).Methods("GET")
BaseRoutes.Channels.Handle("/more/search", ApiUserRequired(searchMoreChannels)).Methods("POST")
BaseRoutes.Channels.Handle("/counts", ApiUserRequired(getChannelCounts)).Methods("GET")
BaseRoutes.Channels.Handle("/members", ApiUserRequired(getMyChannelMembers)).Methods("GET")
BaseRoutes.Channels.Handle("/create", ApiUserRequired(createChannel)).Methods("POST")
@@ -28,6 +30,7 @@ func InitChannel() {
BaseRoutes.Channels.Handle("/update_header", ApiUserRequired(updateChannelHeader)).Methods("POST")
BaseRoutes.Channels.Handle("/update_purpose", ApiUserRequired(updateChannelPurpose)).Methods("POST")
BaseRoutes.Channels.Handle("/update_notify_props", ApiUserRequired(updateNotifyProps)).Methods("POST")
BaseRoutes.Channels.Handle("/autocomplete", ApiUserRequired(autocompleteChannels)).Methods("GET")
BaseRoutes.NeedChannelName.Handle("/join", ApiUserRequired(join)).Methods("POST")
@@ -41,6 +44,7 @@ func InitChannel() {
BaseRoutes.NeedChannel.Handle("/remove", ApiUserRequired(removeMember)).Methods("POST")
BaseRoutes.NeedChannel.Handle("/update_last_viewed_at", ApiUserRequired(updateLastViewedAt)).Methods("POST")
BaseRoutes.NeedChannel.Handle("/set_last_viewed_at", ApiUserRequired(setLastViewedAt)).Methods("POST")
}
func createChannel(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -416,18 +420,29 @@ func getChannels(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
func getMoreChannels(c *Context, w http.ResponseWriter, r *http.Request) {
func getMoreChannelsPage(c *Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
offset, err := strconv.Atoi(params["offset"])
if err != nil {
c.SetInvalidParam("getProfiles", "offset")
return
}
limit, err := strconv.Atoi(params["limit"])
if err != nil {
c.SetInvalidParam("getProfiles", "limit")
return
}
// user is already in the team
if !HasPermissionToTeamContext(c, c.TeamId, model.PERMISSION_LIST_TEAM_CHANNELS) {
return
}
if result := <-Srv.Store.Channel().GetMoreChannels(c.TeamId, c.Session.UserId); result.Err != nil {
if result := <-Srv.Store.Channel().GetMoreChannels(c.TeamId, c.Session.UserId, offset, limit); result.Err != nil {
c.Err = result.Err
return
} else if HandleEtag(result.Data.(*model.ChannelList).Etag(), w, r) {
return
} else {
data := result.Data.(*model.ChannelList)
w.Header().Set(model.HEADER_ETAG_SERVER, data.Etag())
@@ -1182,3 +1197,51 @@ func updateNotifyProps(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
func searchMoreChannels(c *Context, w http.ResponseWriter, r *http.Request) {
props := model.ChannelSearchFromJson(r.Body)
if props == nil {
c.SetInvalidParam("searchMoreChannels", "")
return
}
if c.Session.GetTeamByTeamId(c.TeamId) == nil {
if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) {
return
}
}
if len(props.Term) == 0 {
c.SetInvalidParam("searchMoreChannels", "term")
return
}
if result := <-Srv.Store.Channel().SearchMore(c.Session.UserId, c.TeamId, props.Term); result.Err != nil {
c.Err = result.Err
return
} else {
channels := result.Data.(*model.ChannelList)
w.Write([]byte(channels.ToJson()))
}
}
func autocompleteChannels(c *Context, w http.ResponseWriter, r *http.Request) {
term := r.URL.Query().Get("term")
if c.Session.GetTeamByTeamId(c.TeamId) == nil {
if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) {
return
}
}
var channels *model.ChannelList
if result := <-Srv.Store.Channel().SearchInTeam(c.TeamId, term); result.Err != nil {
c.Err = result.Err
return
} else {
channels = result.Data.(*model.ChannelList)
}
w.Write([]byte(channels.ToJson()))
}

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

@@ -705,7 +705,7 @@ func TestGetChannel(t *testing.T) {
}
}
func TestGetMoreChannel(t *testing.T) {
func TestGetMoreChannelsPage(t *testing.T) {
th := Setup().InitBasic()
Client := th.BasicClient
team := th.BasicTeam
@@ -713,28 +713,64 @@ func TestGetMoreChannel(t *testing.T) {
channel1 := &model.Channel{DisplayName: "A Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
channel2 := &model.Channel{DisplayName: "B Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel2 := &model.Channel{DisplayName: "B Test API Name", Name: "b" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel)
channel3 := &model.Channel{DisplayName: "C Test API Name", Name: "c" + model.NewId() + "a", Type: model.CHANNEL_PRIVATE, TeamId: team.Id}
channel3 = Client.Must(Client.CreateChannel(channel3)).Data.(*model.Channel)
th.LoginBasic2()
rget := Client.Must(Client.GetMoreChannels(""))
channels := rget.Data.(*model.ChannelList)
if (*channels)[0].DisplayName != channel1.DisplayName {
t.Fatal("full name didn't match")
}
if (*channels)[1].DisplayName != channel2.DisplayName {
t.Fatal("full name didn't match")
}
// test etag caching
if cache_result, err := Client.GetMoreChannels(rget.Etag); err != nil {
if r, err := Client.GetMoreChannelsPage(0, 100); err != nil {
t.Fatal(err)
} else if cache_result.Data.(*model.ChannelList) != nil {
t.Log(cache_result.Data)
t.Fatal("cache should be empty")
} else {
channels := r.Data.(*model.ChannelList)
// 1 for BasicChannel, 2 for open channels created above
if len(*channels) != 3 {
t.Fatal("wrong length")
}
if (*channels)[0].DisplayName != channel1.DisplayName {
t.Fatal("full name didn't match")
}
if (*channels)[1].DisplayName != channel2.DisplayName {
t.Fatal("full name didn't match")
}
}
if r, err := Client.GetMoreChannelsPage(0, 1); err != nil {
t.Fatal(err)
} else {
channels := r.Data.(*model.ChannelList)
if len(*channels) != 1 {
t.Fatal("wrong length")
}
if (*channels)[0].DisplayName != channel1.DisplayName {
t.Fatal("full name didn't match")
}
}
if r, err := Client.GetMoreChannelsPage(1, 1); err != nil {
t.Fatal(err)
} else {
channels := r.Data.(*model.ChannelList)
if len(*channels) != 1 {
t.Fatal("wrong length")
}
if (*channels)[0].DisplayName != channel2.DisplayName {
t.Fatal("full name didn't match")
}
}
Client.SetTeamId("junk")
if _, err := Client.GetMoreChannelsPage(0, 1); err == nil {
t.Fatal("should have failed - bad team id")
}
}
@@ -1448,3 +1484,148 @@ func TestGetChannelMember(t *testing.T) {
t.Fatal("should have failed - bad channel and user id")
}
}
func TestSearchMoreChannels(t *testing.T) {
th := Setup().InitBasic()
Client := th.BasicClient
team := th.BasicTeam
channel1 := &model.Channel{DisplayName: "TestAPINameA", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
channel2 := &model.Channel{DisplayName: "TestAPINameB", Name: "b" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel)
th.LoginBasic2()
if result, err := Client.SearchMoreChannels(model.ChannelSearch{Term: "TestAPIName"}); err != nil {
t.Fatal(err)
} else {
channels := result.Data.(*model.ChannelList)
if (*channels)[0].DisplayName != channel1.DisplayName {
t.Fatal("full name didn't match")
}
if (*channels)[1].DisplayName != channel2.DisplayName {
t.Fatal("full name didn't match")
}
}
if result, err := Client.SearchMoreChannels(model.ChannelSearch{Term: "TestAPINameA"}); err != nil {
t.Fatal(err)
} else {
channels := result.Data.(*model.ChannelList)
if (*channels)[0].DisplayName != channel1.DisplayName {
t.Fatal("full name didn't match")
}
}
if result, err := Client.SearchMoreChannels(model.ChannelSearch{Term: "TestAPINameB"}); err != nil {
t.Fatal(err)
} else {
channels := result.Data.(*model.ChannelList)
if (*channels)[0].DisplayName != channel2.DisplayName {
t.Fatal("full name didn't match")
}
}
if result, err := Client.SearchMoreChannels(model.ChannelSearch{Term: channel1.Name}); err != nil {
t.Fatal(err)
} else {
channels := result.Data.(*model.ChannelList)
if (*channels)[0].DisplayName != channel1.DisplayName {
t.Fatal("full name didn't match")
}
}
if _, err := Client.SearchMoreChannels(model.ChannelSearch{Term: ""}); err == nil {
t.Fatal("should have errored - empty term")
}
if result, err := Client.SearchMoreChannels(model.ChannelSearch{Term: "blargh"}); err != nil {
t.Fatal(err)
} else {
channels := result.Data.(*model.ChannelList)
if len(*channels) != 0 {
t.Fatal("should have no channels")
}
}
Client.SetTeamId("junk")
if _, err := Client.SearchMoreChannels(model.ChannelSearch{Term: "blargh"}); err == nil {
t.Fatal("should have errored - bad team id")
}
}
func TestAutocompleteChannels(t *testing.T) {
th := Setup().InitBasic().InitSystemAdmin()
Client := th.BasicClient
team := th.BasicTeam
channel1 := &model.Channel{DisplayName: "TestAPINameA", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
channel2 := &model.Channel{DisplayName: "TestAPINameB", Name: "b" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel)
channel3 := &model.Channel{DisplayName: "BadChannelC", Name: "c" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: model.NewId()}
channel3 = th.SystemAdminClient.Must(th.SystemAdminClient.CreateChannel(channel3)).Data.(*model.Channel)
channel4 := &model.Channel{DisplayName: "BadChannelD", Name: "d" + model.NewId() + "a", Type: model.CHANNEL_PRIVATE, TeamId: team.Id}
channel4 = Client.Must(Client.CreateChannel(channel4)).Data.(*model.Channel)
if result, err := Client.AutocompleteChannels("TestAPIName"); err != nil {
t.Fatal(err)
} else {
channels := result.Data.(*model.ChannelList)
if (*channels)[0].DisplayName != channel1.DisplayName {
t.Fatal("full name didn't match")
}
if (*channels)[1].DisplayName != channel2.DisplayName {
t.Fatal("full name didn't match")
}
}
if result, err := Client.AutocompleteChannels(channel1.Name); err != nil {
t.Fatal(err)
} else {
channels := result.Data.(*model.ChannelList)
if (*channels)[0].DisplayName != channel1.DisplayName {
t.Fatal("full name didn't match")
}
}
if result, err := Client.AutocompleteChannels("BadChannelC"); err != nil {
t.Fatal(err)
} else {
channels := result.Data.(*model.ChannelList)
if len(*channels) != 0 {
t.Fatal("should have been empty")
}
}
if result, err := Client.AutocompleteChannels("BadChannelD"); err != nil {
t.Fatal(err)
} else {
channels := result.Data.(*model.ChannelList)
if len(*channels) != 0 {
t.Fatal("should have been empty")
}
}
Client.SetTeamId("junk")
if _, err := Client.AutocompleteChannels("BadChannelD"); err == nil {
t.Fatal("should have failed - bad team id")
}
}

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

@@ -33,25 +33,22 @@ func (me *JoinProvider) GetCommand(c *Context) *model.Command {
}
func (me *JoinProvider) DoCommand(c *Context, channelId string, message string) *model.CommandResponse {
if result := <-Srv.Store.Channel().GetMoreChannels(c.TeamId, c.Session.UserId); result.Err != nil {
if result := <-Srv.Store.Channel().GetByName(c.TeamId, message); result.Err != nil {
return &model.CommandResponse{Text: c.T("api.command_join.list.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL}
} else {
channels := result.Data.(*model.ChannelList)
channel := result.Data.(*model.Channel)
for _, v := range *channels {
if channel.Name == message {
if v.Name == message {
if v.Type != model.CHANNEL_OPEN {
return &model.CommandResponse{Text: c.T("api.command_join.fail.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL}
}
if err, _ := JoinChannelById(c, c.Session.UserId, v.Id); err != nil {
return &model.CommandResponse{Text: c.T("api.command_join.fail.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL}
}
return &model.CommandResponse{GotoLocation: c.GetTeamURL() + "/channels/" + v.Name, Text: c.T("api.command_join.success"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL}
if channel.Type != model.CHANNEL_OPEN {
return &model.CommandResponse{Text: c.T("api.command_join.fail.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL}
}
if err, _ := JoinChannelById(c, c.Session.UserId, channel.Id); err != nil {
return &model.CommandResponse{Text: c.T("api.command_join.fail.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL}
}
return &model.CommandResponse{GotoLocation: c.GetTeamURL() + "/channels/" + channel.Name, Text: c.T("api.command_join.success"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL}
}
}

39
api/deprecated.go Обычный файл
Просмотреть файл

@@ -0,0 +1,39 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package api
import (
"net/http"
l4g "github.com/alecthomas/log4go"
"github.com/mattermost/platform/model"
"github.com/mattermost/platform/utils"
)
// ONLY FOR APIs SCHEDULED TO BE DEPRECATED
func InitDeprecated() {
l4g.Debug(utils.T("api.channel.init.debug"))
BaseRoutes.Channels.Handle("/more", ApiUserRequired(getMoreChannels)).Methods("GET") // SCHEDULED FOR DEPRECATION IN 3.7
}
func getMoreChannels(c *Context, w http.ResponseWriter, r *http.Request) {
// user is already in the team
if !HasPermissionToTeamContext(c, c.TeamId, model.PERMISSION_LIST_TEAM_CHANNELS) {
return
}
if result := <-Srv.Store.Channel().GetMoreChannels(c.TeamId, c.Session.UserId, 0, 100000); result.Err != nil {
c.Err = result.Err
return
} else if HandleEtag(result.Data.(*model.ChannelList).Etag(), w, r) {
return
} else {
data := result.Data.(*model.ChannelList)
w.Header().Set(model.HEADER_ETAG_SERVER, data.Etag())
w.Write([]byte(data.ToJson()))
}
}

43
api/deprecated_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,43 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package api
import (
"testing"
"github.com/mattermost/platform/model"
)
func TestGetMoreChannel(t *testing.T) {
th := Setup().InitBasic()
Client := th.BasicClient
team := th.BasicTeam
channel1 := &model.Channel{DisplayName: "A Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
channel2 := &model.Channel{DisplayName: "B Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel)
th.LoginBasic2()
rget := Client.Must(Client.GetMoreChannels(""))
channels := rget.Data.(*model.ChannelList)
if (*channels)[0].DisplayName != channel1.DisplayName {
t.Fatal("full name didn't match")
}
if (*channels)[1].DisplayName != channel2.DisplayName {
t.Fatal("full name didn't match")
}
// test etag caching
if cache_result, err := Client.GetMoreChannels(rget.Etag); err != nil {
t.Fatal(err)
} else if cache_result.Data.(*model.ChannelList) != nil {
t.Log(cache_result.Data)
t.Fatal("cache should be empty")
}
}

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

@@ -2712,8 +2712,8 @@ func autocompleteUsersInChannel(c *Context, w http.ResponseWriter, r *http.Reque
searchOptions := map[string]bool{}
searchOptions[store.USER_SEARCH_OPTION_NAMES_ONLY] = true
uchan := Srv.Store.User().SearchInChannel(channelId, term, map[string]bool{})
nuchan := Srv.Store.User().SearchNotInChannel(teamId, channelId, term, map[string]bool{})
uchan := Srv.Store.User().SearchInChannel(channelId, term, searchOptions)
nuchan := Srv.Store.User().SearchNotInChannel(teamId, channelId, term, searchOptions)
autocomplete := &model.UserAutocompleteInChannel{}
@@ -2758,7 +2758,10 @@ func autocompleteUsersInTeam(c *Context, w http.ResponseWriter, r *http.Request)
}
}
uchan := Srv.Store.User().Search(teamId, term, map[string]bool{})
searchOptions := map[string]bool{}
searchOptions[store.USER_SEARCH_OPTION_NAMES_ONLY] = true
uchan := Srv.Store.User().Search(teamId, term, searchOptions)
autocomplete := &model.UserAutocompleteInTeam{}

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

@@ -4035,6 +4035,10 @@
"id": "store.sql_channel.analytics_type_count.app_error",
"translation": "We couldn't get channel type counts"
},
{
"id": "store.sql_channel.search.app_error",
"translation": "We encountered an error searching channels"
},
{
"id": "store.sql_channel.check_open_channel_permissions.app_error",
"translation": "We couldn't check the permissions"

35
model/channel_search.go Обычный файл
Просмотреть файл

@@ -0,0 +1,35 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package model
import (
"encoding/json"
"io"
)
type ChannelSearch struct {
Term string `json:"term"`
}
// ToJson convert a Channel to a json string
func (c *ChannelSearch) ToJson() string {
b, err := json.Marshal(c)
if err != nil {
return ""
} else {
return string(b)
}
}
// ChannelSearchFromJson will decode the input and return a Channel
func ChannelSearchFromJson(data io.Reader) *ChannelSearch {
decoder := json.NewDecoder(data)
var cs ChannelSearch
err := decoder.Decode(&cs)
if err == nil {
return &cs
} else {
return nil
}
}

19
model/channel_search_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,19 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package model
import (
"strings"
"testing"
)
func TestChannelSearchJson(t *testing.T) {
channelSearch := ChannelSearch{Term: NewId()}
json := channelSearch.ToJson()
rchannelSearch := ChannelSearchFromJson(strings.NewReader(json))
if channelSearch.Term != rchannelSearch.Term {
t.Fatal("Terms do not match")
}
}

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

@@ -1164,6 +1164,7 @@ func (c *Client) GetChannel(id, etag string) (*Result, *AppError) {
}
}
// SCHEDULED FOR DEPRECATION IN 3.7 - use GetMoreChannelsPage instead
func (c *Client) GetMoreChannels(etag string) (*Result, *AppError) {
if r, err := c.DoApiGet(c.GetTeamRoute()+"/channels/more", "", etag); err != nil {
return nil, err
@@ -1174,6 +1175,43 @@ func (c *Client) GetMoreChannels(etag string) (*Result, *AppError) {
}
}
// GetMoreChannelsPage will return a page of open channels the user is not in based on
// the provided offset and limit. Must be authenticated.
func (c *Client) GetMoreChannelsPage(offset int, limit int) (*Result, *AppError) {
if r, err := c.DoApiGet(fmt.Sprintf(c.GetTeamRoute()+"/channels/more/%v/%v", offset, limit), "", ""); err != nil {
return nil, err
} else {
defer closeBody(r)
return &Result{r.Header.Get(HEADER_REQUEST_ID),
r.Header.Get(HEADER_ETAG_SERVER), ChannelListFromJson(r.Body)}, nil
}
}
// SearchMoreChannels will return a list of open channels the user is not in, that matches
// the search criteria provided. Must be authenticated.
func (c *Client) SearchMoreChannels(channelSearch ChannelSearch) (*Result, *AppError) {
if r, err := c.DoApiPost(c.GetTeamRoute()+"/channels/more/search", channelSearch.ToJson()); err != nil {
return nil, err
} else {
defer closeBody(r)
return &Result{r.Header.Get(HEADER_REQUEST_ID),
r.Header.Get(HEADER_ETAG_SERVER), ChannelListFromJson(r.Body)}, nil
}
}
// AutocompleteChannels will return a list of open channels that match the provided
// string. Must be authenticated.
func (c *Client) AutocompleteChannels(term string) (*Result, *AppError) {
url := fmt.Sprintf("%s/channels/autocomplete?term=%s", c.GetTeamRoute(), url.QueryEscape(term))
if r, err := c.DoApiGet(url, "", ""); err != nil {
return nil, err
} else {
defer closeBody(r)
return &Result{r.Header.Get(HEADER_REQUEST_ID),
r.Header.Get(HEADER_ETAG_SERVER), ChannelListFromJson(r.Body)}, nil
}
}
func (c *Client) GetChannelCounts(etag string) (*Result, *AppError) {
if r, err := c.DoApiGet(c.GetTeamRoute()+"/channels/counts", "", etag); err != nil {
return nil, err

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

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

@@ -5,6 +5,8 @@ package store
import (
"database/sql"
"fmt"
"strings"
l4g "github.com/alecthomas/log4go"
"github.com/go-gorp/gorp"
@@ -65,6 +67,8 @@ func (s SqlChannelStore) CreateIndexesIfNotExists() {
s.CreateIndexIfNotExists("idx_channelmembers_channel_id", "ChannelMembers", "ChannelId")
s.CreateIndexIfNotExists("idx_channelmembers_user_id", "ChannelMembers", "UserId")
s.CreateFullTextIndexIfNotExists("idx_channels_txt", "Channels", "Name, DisplayName")
}
func (s SqlChannelStore) Save(channel *model.Channel) StoreChannel {
@@ -392,7 +396,7 @@ func (s SqlChannelStore) GetChannels(teamId string, userId string) StoreChannel
return storeChannel
}
func (s SqlChannelStore) GetMoreChannels(teamId string, userId string) StoreChannel {
func (s SqlChannelStore) GetMoreChannels(teamId string, userId string, offset int, limit int) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
@@ -418,8 +422,10 @@ func (s SqlChannelStore) GetMoreChannels(teamId string, userId string) StoreChan
AND TeamId = :TeamId2
AND UserId = :UserId
AND DeleteAt = 0)
ORDER BY DisplayName`,
map[string]interface{}{"TeamId1": teamId, "TeamId2": teamId, "UserId": userId})
ORDER BY DisplayName
LIMIT :Limit
OFFSET :Offset`,
map[string]interface{}{"TeamId1": teamId, "TeamId2": teamId, "UserId": userId, "Limit": limit, "Offset": offset})
if err != nil {
result.Err = model.NewLocAppError("SqlChannelStore.GetMoreChannels", "store.sql_channel.get_more_channels.get.app_error", nil, "teamId="+teamId+", userId="+userId+", err="+err.Error())
@@ -1104,3 +1110,110 @@ func (s SqlChannelStore) GetMembersForUser(teamId string, userId string) StoreCh
return storeChannel
}
func (s SqlChannelStore) SearchInTeam(teamId string, term string) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
searchQuery := `
SELECT
*
FROM
Channels
WHERE
TeamId = :TeamId
AND Type = 'O'
AND DeleteAt = 0
SEARCH_CLAUSE
ORDER BY DisplayName
LIMIT 100`
storeChannel <- s.performSearch(searchQuery, term, map[string]interface{}{"TeamId": teamId})
close(storeChannel)
}()
return storeChannel
}
func (s SqlChannelStore) SearchMore(userId string, teamId string, term string) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
searchQuery := `
SELECT
*
FROM
Channels
WHERE
TeamId = :TeamId
AND Type = 'O'
AND DeleteAt = 0
AND Id NOT IN (SELECT
Channels.Id
FROM
Channels,
ChannelMembers
WHERE
Id = ChannelId
AND TeamId = :TeamId
AND UserId = :UserId
AND DeleteAt = 0)
SEARCH_CLAUSE
ORDER BY DisplayName
LIMIT 100`
storeChannel <- s.performSearch(searchQuery, term, map[string]interface{}{"TeamId": teamId, "UserId": userId})
close(storeChannel)
}()
return storeChannel
}
func (s SqlChannelStore) performSearch(searchQuery string, term string, parameters map[string]interface{}) StoreResult {
result := StoreResult{}
// these chars have special meaning and can be treated as spaces
for _, c := range specialSearchChar {
term = strings.Replace(term, c, " ", -1)
}
if term == "" {
searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", "", 1)
} else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
splitTerm := strings.Fields(term)
for i, t := range strings.Fields(term) {
if i == len(splitTerm)-1 {
splitTerm[i] = t + ":*"
} else {
splitTerm[i] = t + ":* &"
}
}
term = strings.Join(splitTerm, " ")
searchClause := fmt.Sprintf("AND (%s) @@ to_tsquery('simple', :Term)", "Name || ' ' || DisplayName")
searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1)
} else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_MYSQL {
splitTerm := strings.Fields(term)
for i, t := range strings.Fields(term) {
splitTerm[i] = "+" + t + "*"
}
term = strings.Join(splitTerm, " ")
searchClause := fmt.Sprintf("AND MATCH(%s) AGAINST (:Term IN BOOLEAN MODE)", "Name, DisplayName")
searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1)
}
var channels model.ChannelList
parameters["Term"] = term
if _, err := s.GetReplica().Select(&channels, searchQuery, parameters); err != nil {
result.Err = model.NewLocAppError("SqlChannelStore.Search", "store.sql_channel.search.app_error", nil, "term="+term+", "+", "+err.Error())
} else {
result.Data = &channels
}
return result
}

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

@@ -309,7 +309,7 @@ func TestChannelStoreDelete(t *testing.T) {
t.Fatal("invalid number of channels")
}
cresult = <-store.Channel().GetMoreChannels(o1.TeamId, m1.UserId)
cresult = <-store.Channel().GetMoreChannels(o1.TeamId, m1.UserId, 0, 100)
list = cresult.Data.(*model.ChannelList)
if len(*list) != 1 {
@@ -621,7 +621,10 @@ func TestChannelStoreGetMoreChannels(t *testing.T) {
o5.Type = model.CHANNEL_PRIVATE
Must(store.Channel().Save(&o5))
cresult := <-store.Channel().GetMoreChannels(o1.TeamId, m1.UserId)
cresult := <-store.Channel().GetMoreChannels(o1.TeamId, m1.UserId, 0, 100)
if cresult.Err != nil {
t.Fatal(cresult.Err)
}
list := cresult.Data.(*model.ChannelList)
if len(*list) != 1 {
@@ -632,10 +635,38 @@ func TestChannelStoreGetMoreChannels(t *testing.T) {
t.Fatal("missing channel")
}
o6 := model.Channel{}
o6.TeamId = o1.TeamId
o6.DisplayName = "ChannelA"
o6.Name = "a" + model.NewId() + "b"
o6.Type = model.CHANNEL_OPEN
Must(store.Channel().Save(&o6))
cresult = <-store.Channel().GetMoreChannels(o1.TeamId, m1.UserId, 0, 100)
list = cresult.Data.(*model.ChannelList)
if len(*list) != 2 {
t.Fatal("wrong list length")
}
cresult = <-store.Channel().GetMoreChannels(o1.TeamId, m1.UserId, 0, 1)
list = cresult.Data.(*model.ChannelList)
if len(*list) != 1 {
t.Fatal("wrong list length")
}
cresult = <-store.Channel().GetMoreChannels(o1.TeamId, m1.UserId, 1, 1)
list = cresult.Data.(*model.ChannelList)
if len(*list) != 1 {
t.Fatal("wrong list length")
}
if r1 := <-store.Channel().AnalyticsTypeCount(o1.TeamId, model.CHANNEL_OPEN); r1.Err != nil {
t.Fatal(r1.Err)
} else {
if r1.Data.(int64) != 2 {
if r1.Data.(int64) != 3 {
t.Log(r1.Data)
t.Fatal("wrong value")
}
@@ -1055,3 +1086,180 @@ func TestUpdateExtrasByUser(t *testing.T) {
t.Fatal("failed to update extras by user: %v", result.Err)
}
}
func TestChannelStoreSearchMore(t *testing.T) {
Setup()
o1 := model.Channel{}
o1.TeamId = model.NewId()
o1.DisplayName = "ChannelA"
o1.Name = "a" + model.NewId() + "b"
o1.Type = model.CHANNEL_OPEN
Must(store.Channel().Save(&o1))
o2 := model.Channel{}
o2.TeamId = model.NewId()
o2.DisplayName = "Channel2"
o2.Name = "a" + model.NewId() + "b"
o2.Type = model.CHANNEL_OPEN
Must(store.Channel().Save(&o2))
m1 := model.ChannelMember{}
m1.ChannelId = o1.Id
m1.UserId = model.NewId()
m1.NotifyProps = model.GetDefaultChannelNotifyProps()
Must(store.Channel().SaveMember(&m1))
m2 := model.ChannelMember{}
m2.ChannelId = o1.Id
m2.UserId = model.NewId()
m2.NotifyProps = model.GetDefaultChannelNotifyProps()
Must(store.Channel().SaveMember(&m2))
m3 := model.ChannelMember{}
m3.ChannelId = o2.Id
m3.UserId = model.NewId()
m3.NotifyProps = model.GetDefaultChannelNotifyProps()
Must(store.Channel().SaveMember(&m3))
o3 := model.Channel{}
o3.TeamId = o1.TeamId
o3.DisplayName = "ChannelA"
o3.Name = "a" + model.NewId() + "b"
o3.Type = model.CHANNEL_OPEN
Must(store.Channel().Save(&o3))
o4 := model.Channel{}
o4.TeamId = o1.TeamId
o4.DisplayName = "ChannelB"
o4.Name = "a" + model.NewId() + "b"
o4.Type = model.CHANNEL_PRIVATE
Must(store.Channel().Save(&o4))
o5 := model.Channel{}
o5.TeamId = o1.TeamId
o5.DisplayName = "ChannelC"
o5.Name = "a" + model.NewId() + "b"
o5.Type = model.CHANNEL_PRIVATE
Must(store.Channel().Save(&o5))
if result := <-store.Channel().SearchMore(m1.UserId, o1.TeamId, "ChannelA"); result.Err != nil {
t.Fatal(result.Err)
} else {
channels := result.Data.(*model.ChannelList)
if len(*channels) == 0 {
t.Fatal("should not be empty")
}
if (*channels)[0].Name != o3.Name {
t.Fatal("wrong channel returned")
}
}
if result := <-store.Channel().SearchMore(m1.UserId, o1.TeamId, o4.Name); result.Err != nil {
t.Fatal(result.Err)
} else {
channels := result.Data.(*model.ChannelList)
if len(*channels) != 0 {
t.Fatal("should be empty")
}
}
if result := <-store.Channel().SearchMore(m1.UserId, o1.TeamId, o3.Name); result.Err != nil {
t.Fatal(result.Err)
} else {
channels := result.Data.(*model.ChannelList)
if len(*channels) == 0 {
t.Fatal("should not be empty")
}
if (*channels)[0].Name != o3.Name {
t.Fatal("wrong channel returned")
}
}
}
func TestChannelStoreSearchInTeam(t *testing.T) {
Setup()
o1 := model.Channel{}
o1.TeamId = model.NewId()
o1.DisplayName = "ChannelA"
o1.Name = "a" + model.NewId() + "b"
o1.Type = model.CHANNEL_OPEN
Must(store.Channel().Save(&o1))
o2 := model.Channel{}
o2.TeamId = model.NewId()
o2.DisplayName = "Channel2"
o2.Name = "a" + model.NewId() + "b"
o2.Type = model.CHANNEL_OPEN
Must(store.Channel().Save(&o2))
m1 := model.ChannelMember{}
m1.ChannelId = o1.Id
m1.UserId = model.NewId()
m1.NotifyProps = model.GetDefaultChannelNotifyProps()
Must(store.Channel().SaveMember(&m1))
m2 := model.ChannelMember{}
m2.ChannelId = o1.Id
m2.UserId = model.NewId()
m2.NotifyProps = model.GetDefaultChannelNotifyProps()
Must(store.Channel().SaveMember(&m2))
m3 := model.ChannelMember{}
m3.ChannelId = o2.Id
m3.UserId = model.NewId()
m3.NotifyProps = model.GetDefaultChannelNotifyProps()
Must(store.Channel().SaveMember(&m3))
o3 := model.Channel{}
o3.TeamId = o1.TeamId
o3.DisplayName = "ChannelA"
o3.Name = "a" + model.NewId() + "b"
o3.Type = model.CHANNEL_OPEN
Must(store.Channel().Save(&o3))
o4 := model.Channel{}
o4.TeamId = o1.TeamId
o4.DisplayName = "ChannelB"
o4.Name = "a" + model.NewId() + "b"
o4.Type = model.CHANNEL_PRIVATE
Must(store.Channel().Save(&o4))
o5 := model.Channel{}
o5.TeamId = o1.TeamId
o5.DisplayName = "ChannelC"
o5.Name = "a" + model.NewId() + "b"
o5.Type = model.CHANNEL_PRIVATE
Must(store.Channel().Save(&o5))
if result := <-store.Channel().SearchInTeam(o1.TeamId, "ChannelA"); result.Err != nil {
t.Fatal(result.Err)
} else {
channels := result.Data.(*model.ChannelList)
if len(*channels) != 2 {
t.Fatal("wrong length")
}
}
if result := <-store.Channel().SearchInTeam(o1.TeamId, ""); result.Err != nil {
t.Fatal(result.Err)
} else {
channels := result.Data.(*model.ChannelList)
if len(*channels) == 0 {
t.Fatal("should not be empty")
}
}
if result := <-store.Channel().SearchInTeam(o1.TeamId, "blargh"); result.Err != nil {
t.Fatal(result.Err)
} else {
channels := result.Data.(*model.ChannelList)
if len(*channels) != 0 {
t.Fatal("should be empty")
}
}
}

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

@@ -1120,7 +1120,7 @@ func (us SqlUserStore) Search(teamId string, term string, options map[string]boo
SEARCH_CLAUSE
INACTIVE_CLAUSE
ORDER BY Username ASC
LIMIT 50`
LIMIT 100`
} else {
searchQuery = `
SELECT
@@ -1264,7 +1264,7 @@ func (us SqlUserStore) performSearch(searchQuery string, term string, options ma
term = strings.Join(splitTerm, " ")
searchType = convertMySQLFullTextColumnsToPostgres(searchType)
searchClause := fmt.Sprintf("AND (%s) @@ to_tsquery(:Term)", searchType)
searchClause := fmt.Sprintf("AND (%s) @@ to_tsquery('simple', :Term)", searchType)
searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1)
} else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_MYSQL {
splitTerm := strings.Fields(term)

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

@@ -90,7 +90,7 @@ type ChannelStore interface {
PermanentDeleteByTeam(teamId string) StoreChannel
GetByName(team_id string, domain string) StoreChannel
GetChannels(teamId string, userId string) StoreChannel
GetMoreChannels(teamId string, userId string) StoreChannel
GetMoreChannels(teamId string, userId string, offset int, limit int) StoreChannel
GetChannelCounts(teamId string, userId string) StoreChannel
GetTeamChannels(teamId string) StoreChannel
GetAll(teamId string) StoreChannel
@@ -113,6 +113,8 @@ type ChannelStore interface {
AnalyticsTypeCount(teamId string, channelType string) StoreChannel
ExtraUpdateByUser(userId string, time int64) StoreChannel
GetMembersForUser(teamId string, userId string) StoreChannel
SearchInTeam(teamId string, term string) StoreChannel
SearchMore(userId string, teamId string, term string) StoreChannel
}
type PostStore interface {

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

@@ -191,3 +191,55 @@ export function loadChannelsForCurrentUser() {
AsyncClient.getChannels();
AsyncClient.getMyChannelMembers();
}
export function joinChannel(channel, success, error) {
Client.joinChannel(
channel.id,
() => {
ChannelStore.removeMoreChannel(channel.id);
if (success) {
success();
}
},
() => {
if (error) {
error();
}
}
);
}
export function searchMoreChannels(term, success, error) {
Client.searchMoreChannels(
term,
(data) => {
if (success) {
success(data);
}
},
(err) => {
if (error) {
error(err);
}
}
);
}
export function autocompleteChannels(term, success, error) {
Client.autocompleteChannels(
term,
(data) => {
if (success) {
success(data);
}
},
(err) => {
AsyncClient.dispatchError(err, 'autocompleteChannels');
if (error) {
error(err);
}
}
);
}

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

@@ -482,14 +482,6 @@ export function emitUserLoggedOutEvent(redirectTo = '/', shouldSignalLogout = tr
);
}
export function emitJoinChannelEvent(channel, success, failure) {
Client.joinChannel(
channel.id,
success,
failure
);
}
export function emitSearchMentionsEvent(user) {
let terms = '';
if (user.notify_props && user.notify_props.mention_keys) {

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

@@ -1362,6 +1362,7 @@ export default class Client {
this.track('api', 'api_channel_get');
}
// SCHEDULED FOR DEPRECATION IN 3.7 - use getMoreChannelsPage instead
getMoreChannels(success, error) {
request.
get(`${this.getChannelsRoute()}/more`).
@@ -1371,6 +1372,34 @@ export default class Client {
end(this.handleResponse.bind(this, 'getMoreChannels', success, error));
}
getMoreChannelsPage(offset, limit, success, error) {
request.
get(`${this.getChannelsRoute()}/more/${offset}/${limit}`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'getMoreChannelsPage', success, error));
}
searchMoreChannels(term, success, error) {
request.
post(`${this.getChannelsRoute()}/more/search`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
send({term}).
end(this.handleResponse.bind(this, 'searchMoreChannels', success, error));
}
autocompleteChannels(term, success, error) {
request.
get(`${this.getChannelsRoute()}/autocomplete?term=${encodeURIComponent(term)}`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'autocompleteChannels', success, error));
}
getChannelCounts(success, error) {
request.
get(`${this.getChannelsRoute()}/counts`).

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

@@ -1,180 +0,0 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import $ from 'jquery';
import ReactDOM from 'react-dom';
import * as UserAgent from 'utils/user_agent.jsx';
import {localizeMessage} from 'utils/utils.jsx';
import {FormattedMessage} from 'react-intl';
import React from 'react';
import loadingGif from 'images/load.gif';
export default class FilteredChannelList extends React.Component {
constructor(props) {
super(props);
this.handleFilterChange = this.handleFilterChange.bind(this);
this.createChannelRow = this.createChannelRow.bind(this);
this.filterChannels = this.filterChannels.bind(this);
this.state = {
filter: '',
joiningChannel: '',
channels: this.filterChannels(props.channels)
};
}
componentWillReceiveProps(nextProps) {
// assume the channel list is immutable
if (this.props.channels !== nextProps.channels) {
this.setState({
channels: this.filterChannels(nextProps.channels)
});
}
}
componentDidMount() {
// only focus the search box on desktop so that we don't cause the keyboard to open on mobile
if (!UserAgent.isMobile()) {
ReactDOM.findDOMNode(this.refs.filter).focus();
}
}
componentDidUpdate(prevProps, prevState) {
if (prevState.filter !== this.state.filter) {
$(ReactDOM.findDOMNode(this.refs.channelList)).scrollTop(0);
}
}
handleJoin(channel) {
this.setState({joiningChannel: channel.id});
this.props.handleJoin(
channel,
() => {
this.setState({joiningChannel: ''});
});
}
createChannelRow(channel) {
let joinButton;
if (this.state.joiningChannel === channel.id) {
joinButton = (
<img
className='join-channel-loading-gif'
src={loadingGif}
/>
);
} else {
joinButton = (
<button
onClick={this.handleJoin.bind(this, channel)}
className='btn btn-primary'
>
<FormattedMessage
id='more_channels.join'
defaultMessage='Join'
/>
</button>
);
}
return (
<div
className='more-modal__row'
key={channel.id}
>
<div className='more-modal__details'>
<p className='more-modal__name'>{channel.display_name}</p>
<p className='more-modal__description'>{channel.purpose}</p>
</div>
<div className='more-modal__actions'>
{joinButton}
</div>
</div>
);
}
filterChannels(channels) {
if (!this.state || !this.state.filter) {
return channels;
}
return channels.filter((chan) => {
const filter = this.state.filter.toLowerCase();
return Boolean((chan.name.toLowerCase().indexOf(filter) !== -1 || chan.display_name.toLowerCase().indexOf(filter) !== -1) && chan.delete_at === 0);
});
}
handleFilterChange(e) {
this.setState({
filter: e.target.value
});
}
render() {
let channels = this.state.channels;
if (this.state.filter && this.state.filter.length > 0) {
channels = this.filterChannels(channels);
}
let count;
if (channels.length === this.props.channels.length) {
count = (
<FormattedMessage
id='filtered_channels_list.count'
defaultMessage='{count} {count, plural, =0 {0 channels} one {channel} other {channels}}'
values={{
count: channels.length
}}
/>
);
} else {
count = (
<FormattedMessage
id='filtered_channels_list.countTotal'
defaultMessage='{count} {count, plural, =0 {0 channels} one {channel} other {channels}} of {total} Total'
values={{
count: channels.length,
total: this.props.channels.length
}}
/>
);
}
return (
<div className='filtered-user-list'>
<div className='filter-row'>
<div className='col-sm-6'>
<input
ref='filter'
className='form-control filter-textbox'
placeholder={localizeMessage('filtered_channels_list.search', 'Search channels')}
onInput={this.handleFilterChange}
/>
</div>
<div className='col-sm-12'>
<span className='channel-count pull-left'>{count}</span>
</div>
</div>
<div
ref='channelList'
className='more-modal__list'
>
{channels.map(this.createChannelRow)}
</div>
</div>
);
}
}
FilteredChannelList.defaultProps = {
channels: []
};
FilteredChannelList.propTypes = {
channels: React.PropTypes.arrayOf(React.PropTypes.object),
handleJoin: React.PropTypes.func.isRequired
};

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

@@ -2,18 +2,16 @@
// See License.txt for license information.
import $ from 'jquery';
import LoadingScreen from './loading_screen.jsx';
import NewChannelFlow from './new_channel_flow.jsx';
import FilteredChannelList from './filtered_channel_list.jsx';
import SearchableChannelList from './searchable_channel_list.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import UserStore from 'stores/user_store.jsx';
import TeamStore from 'stores/team_store.jsx';
import * as Utils from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import * as GlobalActions from 'actions/global_actions.jsx';
import {joinChannel, searchMoreChannels} from 'actions/channel_actions.jsx';
import {FormattedMessage} from 'react-intl';
import {browserHistory} from 'react-router/es6';
@@ -21,19 +19,28 @@ import {browserHistory} from 'react-router/es6';
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
const CHANNELS_CHUNK_SIZE = 50;
const CHANNELS_PER_PAGE = 50;
const SEARCH_TIMEOUT_MILLISECONDS = 100;
export default class MoreChannels extends React.Component {
constructor(props) {
super(props);
this.onListenerChange = this.onListenerChange.bind(this);
this.onChange = this.onChange.bind(this);
this.handleJoin = this.handleJoin.bind(this);
this.handleNewChannel = this.handleNewChannel.bind(this);
this.nextPage = this.nextPage.bind(this);
this.search = this.search.bind(this);
this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
this.searchTimeoutId = 0;
this.state = {
channelType: '',
showNewChannelModal: false,
search: false,
channels: null,
serverError: null
};
@@ -41,10 +48,10 @@ export default class MoreChannels extends React.Component {
componentDidMount() {
const self = this;
ChannelStore.addChangeListener(this.onListenerChange);
ChannelStore.addChangeListener(this.onChange);
$(this.refs.modal).on('shown.bs.modal', () => {
AsyncClient.getMoreChannels(true);
AsyncClient.getMoreChannelsPage(0, CHANNELS_CHUNK_SIZE * 2);
});
$(this.refs.modal).on('show.bs.modal', (e) => {
@@ -54,25 +61,26 @@ export default class MoreChannels extends React.Component {
}
componentWillUnmount() {
ChannelStore.removeChangeListener(this.onListenerChange);
ChannelStore.removeChangeListener(this.onChange);
}
getStateFromStores() {
return {
channels: ChannelStore.getMoreAll(),
serverError: null
};
}
onListenerChange() {
const newState = this.getStateFromStores();
if (!Utils.areObjectsEqual(newState.channels, this.state.channels)) {
this.setState(newState);
onChange(force) {
if (this.state.search && !force) {
return;
}
this.setState({
channels: ChannelStore.getMoreChannelsList(),
serverError: null
});
}
nextPage(page) {
AsyncClient.getMoreChannelsPage((page + 1) * CHANNELS_PER_PAGE, CHANNELS_PER_PAGE);
}
handleJoin(channel, done) {
GlobalActions.emitJoinChannelEvent(
joinChannel(
channel,
() => {
$(this.refs.modal).modal('hide');
@@ -95,6 +103,28 @@ export default class MoreChannels extends React.Component {
this.setState({showNewChannelModal: true});
}
search(term) {
if (term === '') {
this.onChange(true);
this.setState({search: false});
return;
}
clearTimeout(this.searchTimeoutId);
this.searchTimeoutId = setTimeout(
() => {
searchMoreChannels(
term,
(channels) => {
this.setState({search: true, channels});
}
);
},
SEARCH_TIMEOUT_MILLISECONDS
);
}
render() {
let serverError;
if (this.state.serverError) {
@@ -136,31 +166,6 @@ export default class MoreChannels extends React.Component {
}
}
let moreChannels;
const channels = this.state.channels;
if (channels == null) {
moreChannels = <LoadingScreen/>;
} else if (channels.length) {
moreChannels = (
<FilteredChannelList
channels={channels}
handleJoin={this.handleJoin}
/>
);
} else {
moreChannels = (
<div className='no-channel-message'>
<p className='primary-message'>
<FormattedMessage
id='more_channels.noMore'
defaultMessage='No more channels to join'
/>
</p>
{createChannelHelpText}
</div>
);
}
return (
<div
className='modal fade more-channel__modal'
@@ -200,7 +205,14 @@ export default class MoreChannels extends React.Component {
/>
</div>
<div className='modal-body'>
{moreChannels}
<SearchableChannelList
channels={this.state.channels}
channelsPerPage={CHANNELS_PER_PAGE}
nextPage={this.nextPage}
search={this.search}
handleJoin={this.handleJoin}
noResultsText={createChannelHelpText}
/>
{serverError}
</div>
<div className='modal-footer'>

205
webapp/components/searchable_channel_list.jsx Обычный файл
Просмотреть файл

@@ -0,0 +1,205 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import LoadingScreen from './loading_screen.jsx';
import * as UserAgent from 'utils/user_agent.jsx';
import $ from 'jquery';
import React from 'react';
import {localizeMessage} from 'utils/utils.jsx';
import {FormattedMessage} from 'react-intl';
import loadingGif from 'images/load.gif';
const NEXT_BUTTON_TIMEOUT_MILLISECONDS = 500;
export default class SearchableChannelList extends React.Component {
constructor(props) {
super(props);
this.createChannelRow = this.createChannelRow.bind(this);
this.nextPage = this.nextPage.bind(this);
this.previousPage = this.previousPage.bind(this);
this.doSearch = this.doSearch.bind(this);
this.nextTimeoutId = 0;
this.state = {
joiningChannel: '',
page: 0,
nextDisabled: false
};
}
componentDidMount() {
// only focus the search box on desktop so that we don't cause the keyboard to open on mobile
if (!UserAgent.isMobile()) {
this.refs.filter.focus();
}
}
componentDidUpdate(prevProps, prevState) {
if (prevState.page !== this.state.page) {
$(this.refs.channelList).scrollTop(0);
}
}
handleJoin(channel) {
this.setState({joiningChannel: channel.id});
this.props.handleJoin(
channel,
() => {
this.setState({joiningChannel: ''});
}
);
}
createChannelRow(channel) {
let joinButton;
if (this.state.joiningChannel === channel.id) {
joinButton = (
<img
className='join-channel-loading-gif'
src={loadingGif}
/>
);
} else {
joinButton = (
<button
onClick={this.handleJoin.bind(this, channel)}
className='btn btn-primary'
>
<FormattedMessage
id='more_channels.join'
defaultMessage='Join'
/>
</button>
);
}
return (
<div
className='more-modal__row'
key={channel.id}
>
<div className='more-modal__details'>
<p className='more-modal__name'>{channel.display_name}</p>
<p className='more-modal__description'>{channel.purpose}</p>
</div>
<div className='more-modal__actions'>
{joinButton}
</div>
</div>
);
}
nextPage(e) {
e.preventDefault();
this.setState({page: this.state.page + 1, nextDisabled: true});
this.nextTimeoutId = setTimeout(() => this.setState({nextDisabled: false}), NEXT_BUTTON_TIMEOUT_MILLISECONDS);
this.props.nextPage(this.state.page + 1);
}
previousPage(e) {
e.preventDefault();
this.setState({page: this.state.page - 1});
}
doSearch() {
const term = this.refs.filter.value;
this.props.search(term);
if (term === '') {
this.setState({page: 0});
}
}
render() {
const channels = this.props.channels;
let listContent;
let nextButton;
let previousButton;
if (channels == null) {
listContent = <LoadingScreen/>;
} else if (channels.length === 0) {
listContent = (
<div className='no-channel-message'>
<p className='primary-message'>
<FormattedMessage
id='more_channels.noMore'
defaultMessage='No more channels to join'
/>
</p>
{this.props.noResultsText}
</div>
);
} else {
const pageStart = this.state.page * this.props.channelsPerPage;
const pageEnd = pageStart + this.props.channelsPerPage;
const channelsToDisplay = this.props.channels.slice(pageStart, pageEnd);
listContent = channelsToDisplay.map(this.createChannelRow);
if (channelsToDisplay.length >= this.props.channelsPerPage) {
nextButton = (
<button
className='btn btn-default filter-control filter-control__next'
onClick={this.nextPage}
disabled={this.state.nextDisabled}
>
{'Next'}
</button>
);
}
if (this.state.page > 0) {
previousButton = (
<button
className='btn btn-default filter-control filter-control__prev'
onClick={this.previousPage}
>
{'Previous'}
</button>
);
}
}
return (
<div className='filtered-user-list'>
<div className='filter-row'>
<div className='col-sm-6'>
<input
ref='filter'
className='form-control filter-textbox'
placeholder={localizeMessage('filtered_channels_list.search', 'Search channels')}
onInput={this.doSearch}
/>
</div>
</div>
<div
ref='channelList'
className='more-modal__list'
>
{listContent}
</div>
<div className='filter-controls'>
{previousButton}
{nextButton}
</div>
</div>
);
}
}
SearchableChannelList.defaultProps = {
channels: []
};
SearchableChannelList.propTypes = {
channels: React.PropTypes.arrayOf(React.PropTypes.object),
channelsPerPage: React.PropTypes.number,
nextPage: React.PropTypes.func.isRequired,
search: React.PropTypes.func.isRequired,
handleJoin: React.PropTypes.func.isRequired,
noResultsText: React.PropTypes.object
};

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

@@ -1,15 +1,16 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import SuggestionStore from 'stores/suggestion_store.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import Constants from 'utils/constants.jsx';
import Suggestion from './suggestion.jsx';
const MaxChannelSuggestions = 40;
import {autocompleteChannels} from 'actions/channel_actions.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
import {Constants, ActionTypes} from 'utils/constants.jsx';
import React from 'react';
class ChannelMentionSuggestion extends Suggestion {
render() {
@@ -48,84 +49,71 @@ class ChannelMentionSuggestion extends Suggestion {
}
}
function filterChannelsByPrefix(channels, prefix, limit) {
const filtered = [];
for (const id of Object.keys(channels)) {
if (filtered.length >= limit) {
break;
}
const channel = channels[id];
if (channel.delete_at > 0) {
continue;
}
if (channel.display_name.toLowerCase().startsWith(prefix) || channel.name.startsWith(prefix)) {
filtered.push(channel);
}
export default class ChannelMentionProvider {
constructor() {
this.timeoutId = '';
}
return filtered;
}
componentWillUnmount() {
clearTimeout(this.timeoutId);
}
export default class ChannelMentionProvider {
handlePretextChanged(suggestionId, pretext) {
const captured = (/(^|\s)(~([^~]*))$/i).exec(pretext.toLowerCase());
if (captured) {
const prefix = captured[3];
const channels = ChannelStore.getAll();
const moreChannels = ChannelStore.getMoreAll();
function autocomplete() {
autocompleteChannels(
prefix,
(data) => {
const channels = data;
// Remove private channels from the list.
const publicChannels = channels.filter((channel) => {
return channel.type === 'O';
});
// Wrap channels in an outer object to avoid overwriting the 'type' property.
const wrappedChannels = [];
const wrappedMoreChannels = [];
const moreChannels = [];
channels.forEach((item) => {
if (ChannelStore.get(item.id)) {
wrappedChannels.push({
type: Constants.MENTION_CHANNELS,
channel: item
});
return;
}
// Filter channels by prefix.
const filteredChannels = filterChannelsByPrefix(
publicChannels, prefix, MaxChannelSuggestions);
const filteredMoreChannels = filterChannelsByPrefix(
moreChannels, prefix, MaxChannelSuggestions - filteredChannels.length);
wrappedMoreChannels.push({
type: Constants.MENTION_MORE_CHANNELS,
channel: item
});
// Sort channels by display name.
[filteredChannels, filteredMoreChannels].forEach((items) => {
items.sort((a, b) => {
const aPrefix = a.display_name.startsWith(prefix);
const bPrefix = b.display_name.startsWith(prefix);
moreChannels.push(item);
});
if (aPrefix === bPrefix) {
return a.display_name.localeCompare(b.display_name);
} else if (aPrefix) {
return -1;
const wrapped = wrappedChannels.concat(wrappedMoreChannels);
const mentions = wrapped.map((item) => '~' + item.channel.name);
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_MORE_CHANNELS,
channels: moreChannels
});
AppDispatcher.handleServerAction({
type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS,
id: suggestionId,
matchedPretext: captured[2],
terms: mentions,
items: wrapped,
component: ChannelMentionSuggestion
});
}
);
}
return 1;
});
});
// Wrap channels in an outer object to avoid overwriting the 'type' property.
const wrappedChannels = filteredChannels.map((item) => {
return {
type: Constants.MENTION_CHANNELS,
channel: item
};
});
const wrappedMoreChannels = filteredMoreChannels.map((item) => {
return {
type: Constants.MENTION_MORE_CHANNELS,
channel: item
};
});
const wrapped = wrappedChannels.concat(wrappedMoreChannels);
const mentions = wrapped.map((item) => '~' + item.channel.name);
SuggestionStore.clearSuggestions(suggestionId);
SuggestionStore.addSuggestions(suggestionId, mentions, wrapped, ChannelMentionSuggestion, captured[2]);
this.timeoutId = setTimeout(
autocomplete.bind(this),
Constants.AUTOCOMPLETE_TIMEOUT
);
}
}
}

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

@@ -1,13 +1,16 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import Suggestion from './suggestion.jsx';
import {autocompleteChannels} from 'actions/channel_actions.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import Constants from 'utils/constants.jsx';
import SuggestionStore from 'stores/suggestion_store.jsx';
import Suggestion from './suggestion.jsx';
import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
import {Constants, ActionTypes} from 'utils/constants.jsx';
import React from 'react';
class SearchChannelSuggestion extends Suggestion {
render() {
@@ -30,37 +33,64 @@ class SearchChannelSuggestion extends Suggestion {
}
export default class SearchChannelProvider {
constructor() {
this.timeoutId = '';
}
componentWillUnmount() {
clearTimeout(this.timeoutId);
}
handlePretextChanged(suggestionId, pretext) {
const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext.toLowerCase());
if (captured) {
const channelPrefix = captured[1];
const channels = ChannelStore.getAll();
const publicChannels = [];
const privateChannels = [];
function autocomplete() {
autocompleteChannels(
channelPrefix,
(data) => {
const publicChannels = data;
for (const id of Object.keys(channels)) {
const channel = channels[id];
const localChannels = ChannelStore.getAll();
const privateChannels = [];
// don't show direct channels
if (channel.type !== Constants.DM_CHANNEL && channel.name.startsWith(channelPrefix)) {
if (channel.type === Constants.OPEN_CHANNEL) {
publicChannels.push(channel);
} else {
privateChannels.push(channel);
for (const id of Object.keys(localChannels)) {
const channel = localChannels[id];
if (channel.name.startsWith(channelPrefix) && channel.type === Constants.PRIVATE_CHANNEL) {
privateChannels.push(channel);
}
}
const filteredPublicChannels = [];
publicChannels.forEach((item) => {
if (item.name.startsWith(channelPrefix)) {
filteredPublicChannels.push(item);
}
});
privateChannels.sort((a, b) => a.name.localeCompare(b.name));
filteredPublicChannels.sort((a, b) => a.name.localeCompare(b.name));
const channels = filteredPublicChannels.concat(privateChannels);
const channelNames = channels.map((channel) => channel.name);
AppDispatcher.handleServerAction({
type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS,
id: suggestionId,
matchedPretext: channelPrefix,
terms: channelNames,
items: channels,
component: SearchChannelSuggestion
});
}
}
);
}
publicChannels.sort((a, b) => a.name.localeCompare(b.name));
const publicChannelNames = publicChannels.map((channel) => channel.name);
privateChannels.sort((a, b) => a.name.localeCompare(b.name));
const privateChannelNames = privateChannels.map((channel) => channel.name);
SuggestionStore.clearSuggestions(suggestionId);
SuggestionStore.addSuggestions(suggestionId, publicChannelNames, publicChannels, SearchChannelSuggestion, channelPrefix);
SuggestionStore.addSuggestions(suggestionId, privateChannelNames, privateChannels, SearchChannelSuggestion, channelPrefix);
this.timeoutId = setTimeout(
autocomplete.bind(this),
Constants.AUTOCOMPLETE_TIMEOUT
);
}
}
}

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

@@ -234,11 +234,23 @@ class ChannelStoreClass extends EventEmitter {
}
storeMoreChannels(channels) {
this.moreChannels = channels;
const newChannels = {};
for (let i = 0; i < channels.length; i++) {
newChannels[channels[i].id] = channels[i];
}
this.moreChannels = Object.assign({}, this.moreChannels, newChannels);
}
removeMoreChannel(channelId) {
Reflect.deleteProperty(this.moreChannels, channelId);
}
getMoreChannels() {
return this.moreChannels;
return Object.assign({}, this.moreChannels);
}
getMoreChannelsList() {
return Object.keys(this.moreChannels).map((cid) => this.moreChannels[cid]);
}
storeStats(stats) {

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

@@ -271,6 +271,52 @@ describe('Client.Channels', function() {
});
});
it('getMoreChannelsPage', function(done) {
TestHelper.initBasic(() => {
TestHelper.basicClient().getMoreChannelsPage(
0,
100,
function(data) {
assert.equal(data.length, 0);
done();
},
function(err) {
done(new Error(err.message));
}
);
});
});
it('searchMoreChannels', function(done) {
TestHelper.initBasic(() => {
TestHelper.basicClient().searchMoreChannels(
'blargh',
function(data) {
assert.equal(data.length, 0);
done();
},
function(err) {
done(new Error(err.message));
}
);
});
});
it('autocompleteChannels', function(done) {
TestHelper.initBasic(() => {
TestHelper.basicClient().autocompleteChannels(
TestHelper.basicChannel().name,
function(data) {
assert.equal(data != null, true);
done();
},
function(err) {
done(new Error(err.message));
}
);
});
});
it('getChannelCounts', function(done) {
TestHelper.initBasic(() => {
TestHelper.basicClient().getChannelCounts(

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

@@ -239,6 +239,30 @@ export function getMoreChannels(force) {
}
}
export function getMoreChannelsPage(offset, limit) {
if (isCallInProgress('getMoreChannelsPage')) {
return;
}
callTracker.getMoreChannelsPage = utils.getTimestamp();
Client.getMoreChannelsPage(
offset,
limit,
(data) => {
callTracker.getMoreChannelsPage = 0;
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_MORE_CHANNELS,
channels: data
});
},
(err) => {
callTracker.getMoreChannelsPage = 0;
dispatchError(err, 'getMoreChannelsPage');
}
);
}
export function getChannelStats(channelId = ChannelStore.getCurrentId(), doVersionCheck = false) {
if (isCallInProgress('getChannelStats' + channelId) || channelId == null) {
return;