summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/admin.go31
-rw-r--r--api/admin_test.go50
-rw-r--r--api/api.go2
-rw-r--r--api/context.go34
-rw-r--r--api/server.go8
-rw-r--r--config/config.json1
-rw-r--r--i18n/en.json16
-rw-r--r--i18n/es.json16
-rw-r--r--i18n/pt.json18
-rw-r--r--mattermost.go2
-rw-r--r--model/config.go6
-rw-r--r--store/sql_command_store.go41
-rw-r--r--store/sql_command_store_test.go28
-rw-r--r--store/sql_post_store.go2
-rw-r--r--store/sql_session_store.go30
-rw-r--r--store/sql_session_store_test.go26
-rw-r--r--store/sql_team_store.go19
-rw-r--r--store/sql_team_store_test.go20
-rw-r--r--store/store.go3
-rw-r--r--utils/config.go2
-rw-r--r--web/react/.eslintrc.json2
-rw-r--r--web/react/components/admin_console/admin_controller.jsx4
-rw-r--r--web/react/components/admin_console/analytics.jsx489
-rw-r--r--web/react/components/admin_console/line_chart.jsx50
-rw-r--r--web/react/components/admin_console/service_settings.jsx35
-rw-r--r--web/react/components/admin_console/system_analytics.jsx216
-rw-r--r--web/react/components/admin_console/team_analytics.jsx253
-rw-r--r--web/react/components/admin_console/user_item.jsx61
-rw-r--r--web/react/components/analytics/doughnut_chart.jsx (renamed from web/react/components/admin_console/doughnut_chart.jsx)4
-rw-r--r--web/react/components/analytics/line_chart.jsx90
-rw-r--r--web/react/components/analytics/statistic_count.jsx (renamed from web/react/components/admin_console/statistic_count.jsx)4
-rw-r--r--web/react/components/analytics/system_analytics.jsx346
-rw-r--r--web/react/components/analytics/table_chart.jsx60
-rw-r--r--web/react/components/analytics/team_analytics.jsx235
-rw-r--r--web/react/components/channel_invite_modal.jsx65
-rw-r--r--web/react/components/channel_members_modal.jsx113
-rw-r--r--web/react/components/confirm_modal.jsx10
-rw-r--r--web/react/components/filtered_user_list.jsx136
-rw-r--r--web/react/components/member_list.jsx56
-rw-r--r--web/react/components/member_list_item.jsx142
-rw-r--r--web/react/components/member_list_team.jsx26
-rw-r--r--web/react/components/more_direct_channels.jsx220
-rw-r--r--web/react/components/team_members_dropdown.jsx (renamed from web/react/components/member_list_team_item.jsx)141
-rw-r--r--web/react/components/team_members_modal.jsx41
-rw-r--r--web/react/components/user_list.jsx53
-rw-r--r--web/react/components/user_list_row.jsx65
-rw-r--r--web/react/components/user_settings/manage_languages.jsx36
-rw-r--r--web/react/components/user_settings/user_settings_display.jsx159
-rw-r--r--web/react/stores/analytics_store.jsx85
-rw-r--r--web/react/utils/async_client.jsx270
-rw-r--r--web/react/utils/client.jsx23
-rw-r--r--web/react/utils/constants.jsx22
-rw-r--r--web/react/utils/utils.jsx4
-rw-r--r--web/sass-files/sass/partials/_admin-console.scss8
-rw-r--r--web/sass-files/sass/partials/_modal.scss55
-rw-r--r--web/sass-files/sass/partials/_responsive.scss3
-rw-r--r--web/sass-files/sass/partials/_settings.scss5
-rw-r--r--web/static/i18n/en.json89
-rw-r--r--web/static/i18n/es.json85
-rw-r--r--web/static/i18n/pt.json108
60 files changed, 2293 insertions, 1931 deletions
diff --git a/api/admin.go b/api/admin.go
index d04991353..feb70aae3 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -184,16 +184,18 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) {
name := params["name"]
if name == "standard" {
- var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 4)
+ var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 5)
rows[0] = &model.AnalyticsRow{"channel_open_count", 0}
rows[1] = &model.AnalyticsRow{"channel_private_count", 0}
rows[2] = &model.AnalyticsRow{"post_count", 0}
rows[3] = &model.AnalyticsRow{"unique_user_count", 0}
+ rows[4] = &model.AnalyticsRow{"team_count", 0}
openChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_OPEN)
privateChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_PRIVATE)
postChan := Srv.Store.Post().AnalyticsPostCount(teamId, false, false)
userChan := Srv.Store.User().AnalyticsUniqueUserCount(teamId)
+ teamChan := Srv.Store.Team().AnalyticsTeamCount()
if r := <-openChan; r.Err != nil {
c.Err = r.Err
@@ -223,6 +225,13 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) {
rows[3].Value = float64(r.Data.(int64))
}
+ if r := <-teamChan; r.Err != nil {
+ c.Err = r.Err
+ return
+ } else {
+ rows[4].Value = float64(r.Data.(int64))
+ }
+
w.Write([]byte(rows.ToJson()))
} else if name == "post_counts_day" {
if r := <-Srv.Store.Post().AnalyticsPostCountsByDay(teamId); r.Err != nil {
@@ -239,16 +248,20 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(r.Data.(model.AnalyticsRows).ToJson()))
}
} else if name == "extra_counts" {
- var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 4)
+ var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 6)
rows[0] = &model.AnalyticsRow{"file_post_count", 0}
rows[1] = &model.AnalyticsRow{"hashtag_post_count", 0}
rows[2] = &model.AnalyticsRow{"incoming_webhook_count", 0}
rows[3] = &model.AnalyticsRow{"outgoing_webhook_count", 0}
+ rows[4] = &model.AnalyticsRow{"command_count", 0}
+ rows[5] = &model.AnalyticsRow{"session_count", 0}
fileChan := Srv.Store.Post().AnalyticsPostCount(teamId, true, false)
hashtagChan := Srv.Store.Post().AnalyticsPostCount(teamId, false, true)
iHookChan := Srv.Store.Webhook().AnalyticsIncomingCount(teamId)
oHookChan := Srv.Store.Webhook().AnalyticsOutgoingCount(teamId)
+ commandChan := Srv.Store.Command().AnalyticsCommandCount(teamId)
+ sessionChan := Srv.Store.Session().AnalyticsSessionCount(teamId)
if r := <-fileChan; r.Err != nil {
c.Err = r.Err
@@ -278,6 +291,20 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) {
rows[3].Value = float64(r.Data.(int64))
}
+ if r := <-commandChan; r.Err != nil {
+ c.Err = r.Err
+ return
+ } else {
+ rows[4].Value = float64(r.Data.(int64))
+ }
+
+ if r := <-sessionChan; r.Err != nil {
+ c.Err = r.Err
+ return
+ } else {
+ rows[5].Value = float64(r.Data.(int64))
+ }
+
w.Write([]byte(rows.ToJson()))
} else {
c.SetInvalidParam("getAnalytics", "name")
diff --git a/api/admin_test.go b/api/admin_test.go
index 8a9c82b44..bdea0bc5b 100644
--- a/api/admin_test.go
+++ b/api/admin_test.go
@@ -254,6 +254,16 @@ func TestGetTeamAnalyticsStandard(t *testing.T) {
t.Log(rows.ToJson())
t.Fatal()
}
+
+ if rows[4].Name != "team_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[4].Value == 0 {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
}
if result, err := Client.GetSystemAnalytics("standard"); err != nil {
@@ -300,6 +310,16 @@ func TestGetTeamAnalyticsStandard(t *testing.T) {
t.Log(rows.ToJson())
t.Fatal()
}
+
+ if rows[4].Name != "team_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[4].Value == 0 {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
}
}
@@ -469,6 +489,26 @@ func TestGetTeamAnalyticsExtra(t *testing.T) {
t.Log(rows.ToJson())
t.Fatal()
}
+
+ if rows[4].Name != "command_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[4].Value != 0 {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[5].Name != "session_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[5].Value == 0 {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
}
if result, err := Client.GetSystemAnalytics("extra_counts"); err != nil {
@@ -500,5 +540,15 @@ func TestGetTeamAnalyticsExtra(t *testing.T) {
t.Log(rows.ToJson())
t.Fatal()
}
+
+ if rows[4].Name != "command_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[5].Name != "session_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
}
}
diff --git a/api/api.go b/api/api.go
index d202172d0..4fecd3dd4 100644
--- a/api/api.go
+++ b/api/api.go
@@ -36,7 +36,7 @@ func (me *ServerTemplatePage) Render() string {
T := utils.GetUserTranslations(me.Locale)
me.Props["Footer"] = T("api.templates.email_footer")
me.Html["EmailInfo"] = template.HTML(T("api.templates.email_info",
- map[string]interface{}{"FeedbackEmail": me.ClientCfg["FeedbackEmail"], "SiteName": me.ClientCfg["SiteName"]}))
+ map[string]interface{}{"SupportEmail": me.ClientCfg["SupportEmail"], "SiteName": me.ClientCfg["SiteName"]}))
if err := ServerTemplates.ExecuteTemplate(&text, me.TemplateName, me); err != nil {
l4g.Error(utils.T("api.api.render.error"), me.TemplateName, err)
diff --git a/api/context.go b/api/context.go
index 9e05c5d87..edcdcbfef 100644
--- a/api/context.go
+++ b/api/context.go
@@ -21,6 +21,15 @@ import (
var sessionCache *utils.Cache = utils.NewLru(model.SESSION_CACHE_SIZE)
+var allowedMethods []string = []string{
+ "POST",
+ "GET",
+ "OPTIONS",
+ "PUT",
+ "PATCH",
+ "DELETE",
+}
+
type Context struct {
Session model.Session
RequestId string
@@ -234,6 +243,31 @@ func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
+func (cw *CorsWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if len(*utils.Cfg.ServiceSettings.AllowCorsFrom) > 0 {
+ origin := r.Header.Get("Origin")
+ if *utils.Cfg.ServiceSettings.AllowCorsFrom == "*" || strings.Contains(*utils.Cfg.ServiceSettings.AllowCorsFrom, origin) {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+
+ if r.Method == "OPTIONS" {
+ w.Header().Set(
+ "Access-Control-Allow-Methods",
+ strings.Join(allowedMethods, ", "))
+
+ w.Header().Set(
+ "Access-Control-Allow-Headers",
+ r.Header.Get("Access-Control-Request-Headers"))
+ }
+ }
+ }
+
+ if r.Method == "OPTIONS" {
+ return
+ }
+
+ cw.router.ServeHTTP(w, r)
+}
+
func GetProtocol(r *http.Request) string {
if r.Header.Get(model.HEADER_FORWARDED_PROTO) == "https" {
return "https"
diff --git a/api/server.go b/api/server.go
index 070ed7a70..b84066cbe 100644
--- a/api/server.go
+++ b/api/server.go
@@ -21,6 +21,10 @@ type Server struct {
Router *mux.Router
}
+type CorsWrapper struct {
+ router *mux.Router
+}
+
var Srv *Server
func NewServer() {
@@ -38,7 +42,7 @@ func StartServer() {
l4g.Info(utils.T("api.server.start_server.starting.info"))
l4g.Info(utils.T("api.server.start_server.listening.info"), utils.Cfg.ServiceSettings.ListenAddress)
- var handler http.Handler = Srv.Router
+ var handler http.Handler = &CorsWrapper{Srv.Router}
if utils.Cfg.RateLimitSettings.EnableRateLimiter {
l4g.Info(utils.T("api.server.start_server.rate.info"))
@@ -65,7 +69,7 @@ func StartServer() {
throttled.DefaultDeniedHandler.ServeHTTP(w, r)
})
- handler = th.Throttle(Srv.Router)
+ handler = th.Throttle(&CorsWrapper{Srv.Router})
}
go func() {
diff --git a/config/config.json b/config/config.json
index 2795546f8..b211b16d3 100644
--- a/config/config.json
+++ b/config/config.json
@@ -15,6 +15,7 @@
"EnableDeveloper": false,
"EnableSecurityFixAlert": true,
"EnableInsecureOutgoingConnections": false,
+ "AllowCorsFrom": "",
"SessionLengthWebInDays": 30,
"SessionLengthMobileInDays": 30,
"SessionLengthSSOInDays": 30,
diff --git a/i18n/en.json b/i18n/en.json
index 79b6921a4..0f8b5de7c 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1113,7 +1113,7 @@
},
{
"id": "api.templates.email_info",
- "translation": "Any questions at all, mail us any time: <a href='mailto:{{.FeedbackEmail}}' style='text-decoration: none; color:#2389D7;'>{{.FeedbackEmail}}</a>.<br>Best wishes,<br>The {{.SiteName}} Team<br>"
+ "translation": "Any questions at all, mail us any time: <a href='mailto:{{.SupportEmail}}' style='text-decoration: none; color:#2389D7;'>{{.SupportEmail}}</a>.<br>Best wishes,<br>The {{.SiteName}} Team<br>"
},
{
"id": "api.templates.error.link",
@@ -2648,6 +2648,10 @@
"translation": "We encountered an error updating the channel member"
},
{
+ "id": "store.sql_command.analytics_command_count.app_error",
+ "translation": "We couldn't count the commands"
+ },
+ {
"id": "store.sql_command.save.delete.app_error",
"translation": "We couldn't delete the command"
},
@@ -2896,6 +2900,10 @@
"translation": "We couldn't update the preference"
},
{
+ "id": "store.sql_session.analytics_session_count.app_error",
+ "translation": "We couldn't count the sessions"
+ },
+ {
"id": "store.sql_session.cleanup_expired_sessions.app_error",
"translation": "We encountered an error while deleting expired user sessions"
},
@@ -2960,6 +2968,10 @@
"translation": "We encountered an error updating the system property"
},
{
+ "id": "store.sql_team.analytics_team_count.app_error",
+ "translation": "We couldn't count the teams"
+ },
+ {
"id": "store.sql_team.get.find.app_error",
"translation": "We couldn't find the existing team"
},
@@ -3575,4 +3587,4 @@
"id": "web.watcher_fail.error",
"translation": "Failed to add directory to watcher %v"
}
-] \ No newline at end of file
+]
diff --git a/i18n/es.json b/i18n/es.json
index b290d3854..6670b7f5b 100644
--- a/i18n/es.json
+++ b/i18n/es.json
@@ -1113,7 +1113,7 @@
},
{
"id": "api.templates.email_info",
- "translation": "Si tienes alguna pregunta, escribenos en cualquier momento a: <a href=\"mailto:{{.FeedbackEmail}}\" style=\"text-decoration: none; color:#2389D7;\">{{.FeedbackEmail}}</a>.<br>Los mejores deseos,<br>El Equipo {{.SiteName}}<br>"
+ "translation": "Si tienes alguna pregunta, escribenos en cualquier momento a: <a href=\"mailto:{{.SupportEmail}}\" style=\"text-decoration: none; color:#2389D7;\">{{.SupportEmail}}</a>.<br>Los mejores deseos,<br>El Equipo {{.SiteName}}<br>"
},
{
"id": "api.templates.error.link",
@@ -2648,6 +2648,10 @@
"translation": "Encontramos un error actualizando el miembro del canal"
},
{
+ "id": "store.sql_command.analytics_command_count.app_error",
+ "translation": "No pudimos contar los comandos de barra"
+ },
+ {
"id": "store.sql_command.save.delete.app_error",
"translation": "No pudimos eliminar el comando"
},
@@ -2896,6 +2900,10 @@
"translation": "No pudimos actualizar la preferencia"
},
{
+ "id": "store.sql_session.analytics_session_count.app_error",
+ "translation": "No pudimos contar las sesiones"
+ },
+ {
"id": "store.sql_session.cleanup_expired_sessions.app_error",
"translation": "Encontramos un error mientras se eliminaban las sesiones expiradas del usuario"
},
@@ -2960,6 +2968,10 @@
"translation": "Encontramos un error actualizando las propiedades del sistema"
},
{
+ "id": "store.sql_team.analytics_team_count.app_error",
+ "translation": "No pudimos contar los equipos"
+ },
+ {
"id": "store.sql_team.get.find.app_error",
"translation": "No encontramos el equipo al que perteneces"
},
@@ -3575,4 +3587,4 @@
"id": "web.watcher_fail.error",
"translation": "Falla al agregar el directorio a ser vigilado %v"
}
-] \ No newline at end of file
+]
diff --git a/i18n/pt.json b/i18n/pt.json
index c266879ab..e01db6b22 100644
--- a/i18n/pt.json
+++ b/i18n/pt.json
@@ -1113,7 +1113,7 @@
},
{
"id": "api.templates.email_info",
- "translation": "Qualquer dúvida, envie-nos a qualquer momento: <a href='mailto:{{.FeedbackEmail}}' style='text-decoration: none; color:#2389D7;'>{{.FeedbackEmail}}</a>.<br>Nossos melhores cumprimentos,<br>Equipe {{.SiteName}}<br>"
+ "translation": "Qualquer dúvida, envie-nos a qualquer momento: <a href='mailto:{{.SupportEmail}}' style='text-decoration: none; color:#2389D7;'>{{.SupportEmail}}</a>.<br>Nossos melhores cumprimentos,<br>Equipe {{.SiteName}}<br>"
},
{
"id": "api.templates.error.link",
@@ -2676,6 +2676,10 @@
"translation": "Não foi possível atualizar o comando"
},
{
+ "id": "store.sql_command.analytics_command_count.app_error",
+ "translation": "Não foi possível contar os comandos"
+ },
+ {
"id": "store.sql_license.get.app_error",
"translation": "Encontramos um erro ao obter a licença"
},
@@ -2948,6 +2952,10 @@
"translation": "Não foi possível atualizar as funções"
},
{
+ "id": "store.sql_session.analytics_session_count.app_error",
+ "translation": "Não foi possível contar a sessão"
+ },
+ {
"id": "store.sql_system.get.app_error",
"translation": "Encontramos um erro ao procurar as propriedades de sistema"
},
@@ -3028,6 +3036,10 @@
"translation": "Não foi possível atualizar o nome da equipe"
},
{
+ "id": "store.sql_team.analytics_team_count.app_error",
+ "translation": "Não foi possível contar as equipes"
+ },
+ {
"id": "store.sql_user.analytics_unique_user_count.app_error",
"translation": "Não foi possível obter o número de usuários únicos"
},
@@ -3517,7 +3529,7 @@
},
{
"id": "web.root.singup_info",
- "translation": "Toda comunicação da equipe em um lugar, pesquisavel e acessível em qualquer lugar"
+ "translation": "Toda comunicação da equipe em um lugar, pesquisável e acessível em qualquer lugar"
},
{
"id": "web.root.singup_title",
@@ -3575,4 +3587,4 @@
"id": "web.watcher_fail.error",
"translation": "Falha ao adicionar diretório observador %v"
}
-] \ No newline at end of file
+]
diff --git a/mattermost.go b/mattermost.go
index 7446fe791..b305aac3c 100644
--- a/mattermost.go
+++ b/mattermost.go
@@ -282,7 +282,7 @@ func cmdCreateTeam() {
team.DisplayName = flagTeamName
team.Name = flagTeamName
team.Email = flagEmail
- team.Type = model.TEAM_INVITE
+ team.Type = model.TEAM_OPEN
api.CreateTeam(c, team)
if c.Err != nil {
diff --git a/model/config.go b/model/config.go
index aa3dd3586..82c51224e 100644
--- a/model/config.go
+++ b/model/config.go
@@ -39,6 +39,7 @@ type ServiceSettings struct {
EnableDeveloper *bool
EnableSecurityFixAlert *bool
EnableInsecureOutgoingConnections *bool
+ AllowCorsFrom *string
SessionLengthWebInDays *int
SessionLengthMobileInDays *int
SessionLengthSSOInDays *int
@@ -377,6 +378,11 @@ func (o *Config) SetDefaults() {
o.ServiceSettings.WebsocketSecurePort = new(int)
*o.ServiceSettings.WebsocketSecurePort = 443
}
+
+ if o.ServiceSettings.AllowCorsFrom == nil {
+ o.ServiceSettings.AllowCorsFrom = new(string)
+ *o.ServiceSettings.AllowCorsFrom = ""
+ }
}
func (o *Config) IsValid() *AppError {
diff --git a/store/sql_command_store.go b/store/sql_command_store.go
index 760235e10..074a6e588 100644
--- a/store/sql_command_store.go
+++ b/store/sql_command_store.go
@@ -151,18 +151,49 @@ func (s SqlCommandStore) PermanentDeleteByUser(userId string) StoreChannel {
return storeChannel
}
-func (s SqlCommandStore) Update(hook *model.Command) StoreChannel {
+func (s SqlCommandStore) Update(cmd *model.Command) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
- hook.UpdateAt = model.GetMillis()
+ cmd.UpdateAt = model.GetMillis()
- if _, err := s.GetMaster().Update(hook); err != nil {
- result.Err = model.NewLocAppError("SqlCommandStore.Update", "store.sql_command.save.update.app_error", nil, "id="+hook.Id+", "+err.Error())
+ if _, err := s.GetMaster().Update(cmd); err != nil {
+ result.Err = model.NewLocAppError("SqlCommandStore.Update", "store.sql_command.save.update.app_error", nil, "id="+cmd.Id+", "+err.Error())
} else {
- result.Data = hook
+ result.Data = cmd
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (s SqlCommandStore) AnalyticsCommandCount(teamId string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ query :=
+ `SELECT
+ COUNT(*)
+ FROM
+ Commands
+ WHERE
+ DeleteAt = 0`
+
+ if len(teamId) > 0 {
+ query += " AND TeamId = :TeamId"
+ }
+
+ if c, err := s.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId}); err != nil {
+ result.Err = model.NewLocAppError("SqlCommandStore.AnalyticsCommandCount", "store.sql_command.analytics_command_count.app_error", nil, err.Error())
+ } else {
+ result.Data = c
}
storeChannel <- result
diff --git a/store/sql_command_store_test.go b/store/sql_command_store_test.go
index b4610d4aa..644ebc9ae 100644
--- a/store/sql_command_store_test.go
+++ b/store/sql_command_store_test.go
@@ -153,3 +153,31 @@ func TestCommandStoreUpdate(t *testing.T) {
t.Fatal(r2.Err)
}
}
+
+func TestCommandCount(t *testing.T) {
+ Setup()
+
+ o1 := &model.Command{}
+ o1.CreatorId = model.NewId()
+ o1.Method = model.COMMAND_METHOD_POST
+ o1.TeamId = model.NewId()
+ o1.URL = "http://nowhere.com/"
+
+ o1 = (<-store.Command().Save(o1)).Data.(*model.Command)
+
+ if r1 := <-store.Command().AnalyticsCommandCount(""); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.(int64) == 0 {
+ t.Fatal("should be at least 1 command")
+ }
+ }
+
+ if r2 := <-store.Command().AnalyticsCommandCount(o1.TeamId); r2.Err != nil {
+ t.Fatal(r2.Err)
+ } else {
+ if r2.Data.(int64) != 1 {
+ t.Fatal("should be 1 command")
+ }
+ }
+}
diff --git a/store/sql_post_store.go b/store/sql_post_store.go
index 6a614b6a7..3346534ab 100644
--- a/store/sql_post_store.go
+++ b/store/sql_post_store.go
@@ -947,7 +947,7 @@ func (s SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, mustH
result := StoreResult{}
query :=
- `SELECT
+ `SELECT
COUNT(Posts.Id) AS Value
FROM
Posts,
diff --git a/store/sql_session_store.go b/store/sql_session_store.go
index 8dccc0770..337ad16e6 100644
--- a/store/sql_session_store.go
+++ b/store/sql_session_store.go
@@ -255,3 +255,33 @@ func (me SqlSessionStore) UpdateDeviceId(id, deviceId string) StoreChannel {
return storeChannel
}
+
+func (me SqlSessionStore) AnalyticsSessionCount(teamId string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ query :=
+ `SELECT
+ COUNT(*)
+ FROM
+ Sessions
+ WHERE ExpiresAt > :Time`
+
+ if len(teamId) > 0 {
+ query += " AND TeamId = :TeamId"
+ }
+
+ if c, err := me.GetReplica().SelectInt(query, map[string]interface{}{"Time": model.GetMillis(), "TeamId": teamId}); err != nil {
+ result.Err = model.NewLocAppError("SqlSessionStore.AnalyticsSessionCount", "store.sql_session.analytics_session_count.app_error", nil, err.Error())
+ } else {
+ result.Data = c
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
diff --git a/store/sql_session_store_test.go b/store/sql_session_store_test.go
index 34d3128a6..506695f0e 100644
--- a/store/sql_session_store_test.go
+++ b/store/sql_session_store_test.go
@@ -200,3 +200,29 @@ func TestSessionStoreUpdateLastActivityAt(t *testing.T) {
}
}
+
+func TestSessionCount(t *testing.T) {
+ Setup()
+
+ s1 := model.Session{}
+ s1.UserId = model.NewId()
+ s1.TeamId = model.NewId()
+ s1.ExpiresAt = model.GetMillis() + 100000
+ Must(store.Session().Save(&s1))
+
+ if r1 := <-store.Session().AnalyticsSessionCount(""); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.(int64) == 0 {
+ t.Fatal("should have at least 1 session")
+ }
+ }
+
+ if r2 := <-store.Session().AnalyticsSessionCount(s1.TeamId); r2.Err != nil {
+ t.Fatal(r2.Err)
+ } else {
+ if r2.Data.(int64) != 1 {
+ t.Fatal("should have 1 session")
+ }
+ }
+}
diff --git a/store/sql_team_store.go b/store/sql_team_store.go
index 86ab9ac04..1893268c8 100644
--- a/store/sql_team_store.go
+++ b/store/sql_team_store.go
@@ -317,3 +317,22 @@ func (s SqlTeamStore) PermanentDelete(teamId string) StoreChannel {
return storeChannel
}
+
+func (s SqlTeamStore) AnalyticsTeamCount() StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ if c, err := s.GetReplica().SelectInt("SELECT COUNT(*) FROM Teams WHERE DeleteAt = 0", map[string]interface{}{}); err != nil {
+ result.Err = model.NewLocAppError("SqlTeamStore.AnalyticsTeamCount", "store.sql_team.analytics_team_count.app_error", nil, err.Error())
+ } else {
+ result.Data = c
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
diff --git a/store/sql_team_store_test.go b/store/sql_team_store_test.go
index 7dc31cbe2..743ef053f 100644
--- a/store/sql_team_store_test.go
+++ b/store/sql_team_store_test.go
@@ -261,3 +261,23 @@ func TestDelete(t *testing.T) {
t.Fatal(r1.Err)
}
}
+
+func TestTeamCount(t *testing.T) {
+ Setup()
+
+ o1 := model.Team{}
+ o1.DisplayName = "DisplayName"
+ o1.Name = "a" + model.NewId() + "b"
+ o1.Email = model.NewId() + "@nowhere.com"
+ o1.Type = model.TEAM_OPEN
+ o1.AllowTeamListing = true
+ Must(store.Team().Save(&o1))
+
+ if r1 := <-store.Team().AnalyticsTeamCount(); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else {
+ if r1.Data.(int64) == 0 {
+ t.Fatal("should be at least 1 team")
+ }
+ }
+}
diff --git a/store/store.go b/store/store.go
index 397601543..b041cfa25 100644
--- a/store/store.go
+++ b/store/store.go
@@ -55,6 +55,7 @@ type TeamStore interface {
GetAllTeamListing() StoreChannel
GetByInviteId(inviteId string) StoreChannel
PermanentDelete(teamId string) StoreChannel
+ AnalyticsTeamCount() StoreChannel
}
type ChannelStore interface {
@@ -141,6 +142,7 @@ type SessionStore interface {
UpdateLastActivityAt(sessionId string, time int64) StoreChannel
UpdateRoles(userId string, roles string) StoreChannel
UpdateDeviceId(id string, deviceId string) StoreChannel
+ AnalyticsSessionCount(teamId string) StoreChannel
}
type AuditStore interface {
@@ -196,6 +198,7 @@ type CommandStore interface {
Delete(commandId string, time int64) StoreChannel
PermanentDeleteByUser(userId string) StoreChannel
Update(hook *model.Command) StoreChannel
+ AnalyticsCommandCount(teamId string) StoreChannel
}
type PreferenceStore interface {
diff --git a/utils/config.go b/utils/config.go
index 3e4ba5c5b..63906c345 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -236,5 +236,7 @@ func getClientConfig(c *model.Config) map[string]string {
props["WebsocketPort"] = fmt.Sprintf("%v", *c.ServiceSettings.WebsocketPort)
props["WebsocketSecurePort"] = fmt.Sprintf("%v", *c.ServiceSettings.WebsocketSecurePort)
+ props["AllowCorsFrom"] = *c.ServiceSettings.AllowCorsFrom
+
return props
}
diff --git a/web/react/.eslintrc.json b/web/react/.eslintrc.json
index 51345113b..bc06643c1 100644
--- a/web/react/.eslintrc.json
+++ b/web/react/.eslintrc.json
@@ -192,7 +192,7 @@
"react/display-name": [2, { "ignoreTranspilerName": false }],
"react/no-deprecated": 2,
"react/no-is-mounted": 2,
- "react/no-string-refs": 1,
+ "react/no-string-refs": 0,
"react/jsx-pascal-case": 2,
"react/jsx-indent": [1, 4],
"react/jsx-equals-spacing": [2, "never"],
diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx
index de0b085bc..32ed70a99 100644
--- a/web/react/components/admin_console/admin_controller.jsx
+++ b/web/react/components/admin_console/admin_controller.jsx
@@ -21,10 +21,10 @@ import TeamSettingsTab from './team_settings.jsx';
import ServiceSettingsTab from './service_settings.jsx';
import LegalAndSupportSettingsTab from './legal_and_support_settings.jsx';
import TeamUsersTab from './team_users.jsx';
-import TeamAnalyticsTab from './team_analytics.jsx';
+import TeamAnalyticsTab from '../analytics/team_analytics.jsx';
import LdapSettingsTab from './ldap_settings.jsx';
import LicenseSettingsTab from './license_settings.jsx';
-import SystemAnalyticsTab from './system_analytics.jsx';
+import SystemAnalyticsTab from '../analytics/system_analytics.jsx';
export default class AdminController extends React.Component {
constructor(props) {
diff --git a/web/react/components/admin_console/analytics.jsx b/web/react/components/admin_console/analytics.jsx
deleted file mode 100644
index ec9ad4da0..000000000
--- a/web/react/components/admin_console/analytics.jsx
+++ /dev/null
@@ -1,489 +0,0 @@
-// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-import Constants from '../../utils/constants.jsx';
-import LineChart from './line_chart.jsx';
-import DoughnutChart from './doughnut_chart.jsx';
-import StatisticCount from './statistic_count.jsx';
-
-var Tooltip = ReactBootstrap.Tooltip;
-var OverlayTrigger = ReactBootstrap.OverlayTrigger;
-
-import {injectIntl, intlShape, defineMessages, FormattedMessage, FormattedDate} from 'mm-intl';
-
-const holders = defineMessages({
- analyticsTotalUsers: {
- id: 'admin.analytics.totalUsers',
- defaultMessage: 'Total Users'
- },
- analyticsPublicChannels: {
- id: 'admin.analytics.publicChannels',
- defaultMessage: 'Public Channels'
- },
- analyticsPrivateGroups: {
- id: 'admin.analytics.privateGroups',
- defaultMessage: 'Private Groups'
- },
- analyticsTotalPosts: {
- id: 'admin.analytics.totalPosts',
- defaultMessage: 'Total Posts'
- },
- analyticsFilePosts: {
- id: 'admin.analytics.totalFilePosts',
- defaultMessage: 'Posts with Files'
- },
- analyticsHashtagPosts: {
- id: 'admin.analytics.totalHashtagPosts',
- defaultMessage: 'Posts with Hashtags'
- },
- analyticsIncomingHooks: {
- id: 'admin.analytics.totalIncomingWebhooks',
- defaultMessage: 'Incoming Webhooks'
- },
- analyticsOutgoingHooks: {
- id: 'admin.analytics.totalOutgoingWebhooks',
- defaultMessage: 'Outgoing Webhooks'
- },
- analyticsChannelTypes: {
- id: 'admin.analytics.channelTypes',
- defaultMessage: 'Channel Types'
- },
- analyticsTextPosts: {
- id: 'admin.analytics.textPosts',
- defaultMessage: 'Posts with Text-only'
- },
- analyticsPostTypes: {
- id: 'admin.analytics.postTypes',
- defaultMessage: 'Posts, Files and Hashtags'
- }
-});
-
-export default class Analytics extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {};
- }
-
- render() { // in the future, break down these into smaller components
- const {formatMessage} = this.props.intl;
-
- var serverError = '';
- if (this.props.serverError) {
- serverError = <div className='form-group has-error'><label className='control-label'>{this.props.serverError}</label></div>;
- }
-
- let loading = (
- <h5>
- <FormattedMessage
- id='admin.analytics.loading'
- defaultMessage='Loading...'
- />
- </h5>
- );
-
- let firstRow;
- let extraGraphs;
- if (this.props.showAdvanced) {
- firstRow = (
- <div className='row'>
- <StatisticCount
- title={formatMessage(holders.analyticsTotalUsers)}
- icon='fa-users'
- count={this.props.uniqueUserCount}
- />
- <StatisticCount
- title={formatMessage(holders.analyticsTotalPosts)}
- icon='fa-comment'
- count={this.props.postCount}
- />
- <StatisticCount
- title={formatMessage(holders.analyticsIncomingHooks)}
- icon='fa-arrow-down'
- count={this.props.incomingWebhookCount}
- />
- <StatisticCount
- title={formatMessage(holders.analyticsOutgoingHooks)}
- icon='fa-arrow-up'
- count={this.props.outgoingWebhookCount}
- />
- </div>
- );
-
- const channelTypeData = [
- {
- value: this.props.channelOpenCount,
- color: '#46BFBD',
- highlight: '#5AD3D1',
- label: formatMessage(holders.analyticsPublicChannels)
- },
- {
- value: this.props.channelPrivateCount,
- color: '#FDB45C',
- highlight: '#FFC870',
- label: formatMessage(holders.analyticsPrivateGroups)
- }
- ];
-
- const postTypeData = [
- {
- value: this.props.filePostCount,
- color: '#46BFBD',
- highlight: '#5AD3D1',
- label: formatMessage(holders.analyticsFilePosts)
- },
- {
- value: this.props.filePostCount,
- color: '#F7464A',
- highlight: '#FF5A5E',
- label: formatMessage(holders.analyticsHashtagPosts)
- },
- {
- value: this.props.postCount - this.props.filePostCount - this.props.hashtagPostCount,
- color: '#FDB45C',
- highlight: '#FFC870',
- label: formatMessage(holders.analyticsTextPosts)
- }
- ];
-
- extraGraphs = (
- <div className='row'>
- <DoughnutChart
- title={formatMessage(holders.analyticsChannelTypes)}
- data={channelTypeData}
- width='300'
- height='225'
- />
- <DoughnutChart
- title={formatMessage(holders.analyticsPostTypes)}
- data={postTypeData}
- width='300'
- height='225'
- />
- </div>
- );
- } else {
- firstRow = (
- <div className='row'>
- <StatisticCount
- title={formatMessage(holders.analyticsTotalUsers)}
- icon='fa-users'
- count={this.props.uniqueUserCount}
- />
- <StatisticCount
- title={formatMessage(holders.analyticsPublicChannels)}
- icon='fa-globe'
- count={this.props.channelOpenCount}
- />
- <StatisticCount
- title={formatMessage(holders.analyticsPrivateGroups)}
- icon='fa-lock'
- count={this.props.channelPrivateCount}
- />
- <StatisticCount
- title={formatMessage(holders.analyticsTotalPosts)}
- icon='fa-comment'
- count={this.props.postCount}
- />
- </div>
- );
- }
-
- let postCountsByDay;
- if (this.props.postCountsDay == null) {
- postCountsByDay = (
- <div className='col-sm-12'>
- <div className='total-count by-day'>
- <div className='title'>
- <FormattedMessage
- id='admin.analytics.totalPosts'
- defaultMessage='Total Posts'
- />
- </div>
- <div className='content'>{loading}</div>
- </div>
- </div>
- );
- } else {
- let content;
- if (this.props.postCountsDay.labels.length === 0) {
- content = (
- <h5>
- <FormattedMessage
- id='admin.analytics.meaningful'
- defaultMessage='Not enough data for a meaningful representation.'
- />
- </h5>
- );
- } else {
- content = (
- <LineChart
- data={this.props.postCountsDay}
- width='740'
- height='225'
- />
- );
- }
- postCountsByDay = (
- <div className='col-sm-12'>
- <div className='total-count by-day'>
- <div className='title'>
- <FormattedMessage
- id='admin.analytics.totalPosts'
- defaultMessage='Total Posts'
- />
- </div>
- <div className='content'>
- {content}
- </div>
- </div>
- </div>
- );
- }
-
- let usersWithPostsByDay;
- if (this.props.userCountsWithPostsDay == null) {
- usersWithPostsByDay = (
- <div className='col-sm-12'>
- <div className='total-count by-day'>
- <div className='title'>
- <FormattedMessage
- id='admin.analytics.activeUsers'
- defaultMessage='Active Users With Posts'
- />
- </div>
- <div className='content'>{loading}</div>
- </div>
- </div>
- );
- } else {
- let content;
- if (this.props.userCountsWithPostsDay.labels.length === 0) {
- content = (
- <h5>
- <FormattedMessage
- id='admin.analytics.meaningful'
- defaultMessage='Not enough data for a meaningful representation.'
- />
- </h5>
- );
- } else {
- content = (
- <LineChart
- data={this.props.userCountsWithPostsDay}
- width='740'
- height='225'
- />
- );
- }
- usersWithPostsByDay = (
- <div className='col-sm-12'>
- <div className='total-count by-day'>
- <div className='title'>
- <FormattedMessage
- id='admin.analytics.activeUsers'
- defaultMessage='Active Users With Posts'
- />
- </div>
- <div className='content'>
- {content}
- </div>
- </div>
- </div>
- );
- }
-
- let recentActiveUser;
- if (this.props.recentActiveUsers != null) {
- let content;
- if (this.props.recentActiveUsers.length === 0) {
- content = loading;
- } else {
- content = (
- <table>
- <tbody>
- {
- this.props.recentActiveUsers.map((user) => {
- const tooltip = (
- <Tooltip id={'recent-user-email-tooltip-' + user.id}>
- {user.email}
- </Tooltip>
- );
-
- return (
- <tr key={'recent-user-table-entry-' + user.id}>
- <td>
- <OverlayTrigger
- delayShow={Constants.OVERLAY_TIME_DELAY}
- placement='top'
- overlay={tooltip}
- >
- <time>
- {user.username}
- </time>
- </OverlayTrigger>
- </td>
- <td>
- <FormattedDate
- value={user.last_activity_at}
- day='numeric'
- month='long'
- year='numeric'
- hour12={true}
- hour='2-digit'
- minute='2-digit'
- />
- </td>
- </tr>
- );
- })
- }
- </tbody>
- </table>
- );
- }
- recentActiveUser = (
- <div className='col-sm-6'>
- <div className='total-count recent-active-users'>
- <div className='title'>
- <FormattedMessage
- id='admin.analytics.recentActive'
- defaultMessage='Recent Active Users'
- />
- </div>
- <div className='content'>
- {content}
- </div>
- </div>
- </div>
- );
- }
-
- let newUsers;
- if (this.props.newlyCreatedUsers != null) {
- let content;
- if (this.props.newlyCreatedUsers.length === 0) {
- content = loading;
- } else {
- content = (
- <table>
- <tbody>
- {
- this.props.newlyCreatedUsers.map((user) => {
- const tooltip = (
- <Tooltip id={'new-user-email-tooltip-' + user.id}>
- {user.email}
- </Tooltip>
- );
-
- return (
- <tr key={'new-user-table-entry-' + user.id}>
- <td>
- <OverlayTrigger
- delayShow={Constants.OVERLAY_TIME_DELAY}
- placement='top'
- overlay={tooltip}
- >
- <time>
- {user.username}
- </time>
- </OverlayTrigger>
- </td>
- <td>
- <FormattedDate
- value={user.create_at}
- day='numeric'
- month='long'
- year='numeric'
- hour12={true}
- hour='2-digit'
- minute='2-digit'
- />
- </td>
- </tr>
- );
- })
- }
- </tbody>
- </table>
- );
- }
- newUsers = (
- <div className='col-sm-6'>
- <div className='total-count recent-active-users'>
- <div className='title'>
- <FormattedMessage
- id='admin.analytics.newlyCreated'
- defaultMessage='Newly Created Users'
- />
- </div>
- <div className='content'>
- {content}
- </div>
- </div>
- </div>
- );
- }
-
- return (
- <div className='wrapper--fixed team_statistics'>
- <h3>
- <FormattedMessage
- id='admin.analytics.title'
- defaultMessage='Statistics for {title}'
- values={{
- title: this.props.title
- }}
- />
- </h3>
- {serverError}
- {firstRow}
- {extraGraphs}
- <div className='row'>
- {postCountsByDay}
- </div>
- <div className='row'>
- {usersWithPostsByDay}
- </div>
- <div className='row'>
- {recentActiveUser}
- {newUsers}
- </div>
- </div>
- );
- }
-}
-
-Analytics.defaultProps = {
- title: null,
- channelOpenCount: null,
- channelPrivateCount: null,
- postCount: null,
- postCountsDay: null,
- userCountsWithPostsDay: null,
- recentActiveUsers: null,
- newlyCreatedUsers: null,
- uniqueUserCount: null,
- serverError: null
-};
-
-Analytics.propTypes = {
- intl: intlShape.isRequired,
- title: React.PropTypes.string,
- channelOpenCount: React.PropTypes.number,
- channelPrivateCount: React.PropTypes.number,
- postCount: React.PropTypes.number,
- showAdvanced: React.PropTypes.bool,
- filePostCount: React.PropTypes.number,
- hashtagPostCount: React.PropTypes.number,
- incomingWebhookCount: React.PropTypes.number,
- outgoingWebhookCount: React.PropTypes.number,
- postCountsDay: React.PropTypes.object,
- userCountsWithPostsDay: React.PropTypes.object,
- recentActiveUsers: React.PropTypes.array,
- newlyCreatedUsers: React.PropTypes.array,
- uniqueUserCount: React.PropTypes.number,
- serverError: React.PropTypes.string
-};
-
-export default injectIntl(Analytics);
diff --git a/web/react/components/admin_console/line_chart.jsx b/web/react/components/admin_console/line_chart.jsx
deleted file mode 100644
index 7e2f95c84..000000000
--- a/web/react/components/admin_console/line_chart.jsx
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-export default class LineChart extends React.Component {
- constructor(props) {
- super(props);
-
- this.initChart = this.initChart.bind(this);
- this.chart = null;
- }
-
- componentDidMount() {
- this.initChart(this.props);
- }
-
- componentWillReceiveProps(nextProps) {
- if (this.chart) {
- this.chart.destroy();
- this.initChart(nextProps);
- }
- }
-
- componentWillUnmount() {
- if (this.chart) {
- this.chart.destroy();
- }
- }
-
- initChart(props) {
- var el = ReactDOM.findDOMNode(this);
- var ctx = el.getContext('2d');
- this.chart = new Chart(ctx).Line(props.data, props.options || {}); //eslint-disable-line new-cap
- }
-
- render() {
- return (
- <canvas
- width={this.props.width}
- height={this.props.height}
- />
- );
- }
-}
-
-LineChart.propTypes = {
- width: React.PropTypes.string,
- height: React.PropTypes.string,
- data: React.PropTypes.object,
- options: React.PropTypes.object
-};
diff --git a/web/react/components/admin_console/service_settings.jsx b/web/react/components/admin_console/service_settings.jsx
index 047c7eb8d..9ed81b6a3 100644
--- a/web/react/components/admin_console/service_settings.jsx
+++ b/web/react/components/admin_console/service_settings.jsx
@@ -31,6 +31,10 @@ var holders = defineMessages({
id: 'admin.service.sessionDaysEx',
defaultMessage: 'Ex "30"'
},
+ corsExample: {
+ id: 'admin.service.corsEx',
+ defaultMessage: 'http://example.com'
+ },
saving: {
id: 'admin.service.saving',
defaultMessage: 'Saving Config...'
@@ -131,6 +135,8 @@ class ServiceSettings extends React.Component {
config.ServiceSettings.SessionCacheInMinutes = SessionCacheInMinutes;
ReactDOM.findDOMNode(this.refs.SessionCacheInMinutes).value = SessionCacheInMinutes;
+ config.ServiceSettings.AllowCorsFrom = ReactDOM.findDOMNode(this.refs.AllowCorsFrom).value.trim();
+
Client.saveConfig(
config,
() => {
@@ -766,6 +772,35 @@ class ServiceSettings extends React.Component {
<div className='form-group'>
<label
className='control-label col-sm-4'
+ htmlFor='AllowCorsFrom'
+ >
+ <FormattedMessage
+ id='admin.service.corsTitle'
+ defaultMessage='Allow Cross-origin Requests from:'
+ />
+ </label>
+ <div className='col-sm-8'>
+ <input
+ type='text'
+ className='form-control'
+ id='AllowCorsFrom'
+ ref='AllowCorsFrom'
+ placeholder={formatMessage(holders.corsExample)}
+ defaultValue={this.props.config.ServiceSettings.AllowCorsFrom}
+ onChange={this.handleChange}
+ />
+ <p className='help-text'>
+ <FormattedMessage
+ id='admin.service.corsDescription'
+ defaultMessage='Enable HTTP Cross origin request from a specific domain. Use "*" if you want to allow CORS from any domain or leave it blank to disable it.'
+ />
+ </p>
+ </div>
+ </div>
+
+ <div className='form-group'>
+ <label
+ className='control-label col-sm-4'
htmlFor='SessionLengthWebInDays'
>
<FormattedMessage
diff --git a/web/react/components/admin_console/system_analytics.jsx b/web/react/components/admin_console/system_analytics.jsx
deleted file mode 100644
index f983db177..000000000
--- a/web/react/components/admin_console/system_analytics.jsx
+++ /dev/null
@@ -1,216 +0,0 @@
-// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-import Analytics from './analytics.jsx';
-import * as Client from '../../utils/client.jsx';
-
-import {injectIntl, intlShape, defineMessages} from 'mm-intl';
-
-const labels = defineMessages({
- totalPosts: {
- id: 'admin.system_analytics.totalPosts',
- defaultMessage: 'Total Posts'
- },
- activeUsers: {
- id: 'admin.system_analytics.activeUsers',
- defaultMessage: 'Active Users With Posts'
- },
- title: {
- id: 'admin.system_analytics.title',
- defaultMessage: 'the System'
- }
-});
-
-class SystemAnalytics extends React.Component {
- constructor(props) {
- super(props);
-
- this.getData = this.getData.bind(this);
-
- this.state = { // most of this state should be from a store in the future
- users: null,
- serverError: null,
- channel_open_count: null,
- channel_private_count: null,
- post_count: null,
- post_counts_day: null,
- user_counts_with_posts_day: null,
- recent_active_users: null,
- newly_created_users: null,
- unique_user_count: null
- };
- }
-
- componentDidMount() {
- this.getData();
- }
-
- getData() { // should be moved to an action creator eventually
- const {formatMessage} = this.props.intl;
- Client.getSystemAnalytics(
- 'standard',
- (data) => {
- for (var index in data) {
- if (data[index].name === 'channel_open_count') {
- this.setState({channel_open_count: data[index].value});
- }
-
- if (data[index].name === 'channel_private_count') {
- this.setState({channel_private_count: data[index].value});
- }
-
- if (data[index].name === 'post_count') {
- this.setState({post_count: data[index].value});
- }
-
- if (data[index].name === 'unique_user_count') {
- this.setState({unique_user_count: data[index].value});
- }
- }
- },
- (err) => {
- this.setState({serverError: err.message});
- }
- );
-
- Client.getSystemAnalytics(
- 'post_counts_day',
- (data) => {
- data.reverse();
-
- var chartData = {
- labels: [],
- datasets: [{
- label: formatMessage(labels.totalPosts),
- fillColor: 'rgba(151,187,205,0.2)',
- strokeColor: 'rgba(151,187,205,1)',
- pointColor: 'rgba(151,187,205,1)',
- pointStrokeColor: '#fff',
- pointHighlightFill: '#fff',
- pointHighlightStroke: 'rgba(151,187,205,1)',
- data: []
- }]
- };
-
- for (var index in data) {
- if (data[index]) {
- var row = data[index];
- chartData.labels.push(row.name);
- chartData.datasets[0].data.push(row.value);
- }
- }
-
- this.setState({post_counts_day: chartData});
- },
- (err) => {
- this.setState({serverError: err.message});
- }
- );
-
- Client.getSystemAnalytics(
- 'user_counts_with_posts_day',
- (data) => {
- data.reverse();
-
- var chartData = {
- labels: [],
- datasets: [{
- label: formatMessage(labels.activeUsers),
- fillColor: 'rgba(151,187,205,0.2)',
- strokeColor: 'rgba(151,187,205,1)',
- pointColor: 'rgba(151,187,205,1)',
- pointStrokeColor: '#fff',
- pointHighlightFill: '#fff',
- pointHighlightStroke: 'rgba(151,187,205,1)',
- data: []
- }]
- };
-
- for (var index in data) {
- if (data[index]) {
- var row = data[index];
- chartData.labels.push(row.name);
- chartData.datasets[0].data.push(row.value);
- }
- }
-
- this.setState({user_counts_with_posts_day: chartData});
- },
- (err) => {
- this.setState({serverError: err.message});
- }
- );
-
- if (global.window.mm_license.IsLicensed === 'true') {
- Client.getSystemAnalytics(
- 'extra_counts',
- (data) => {
- for (var index in data) {
- if (data[index].name === 'file_post_count') {
- this.setState({file_post_count: data[index].value});
- }
-
- if (data[index].name === 'hashtag_post_count') {
- this.setState({hashtag_post_count: data[index].value});
- }
-
- if (data[index].name === 'incoming_webhook_count') {
- this.setState({incoming_webhook_count: data[index].value});
- }
-
- if (data[index].name === 'outgoing_webhook_count') {
- this.setState({outgoing_webhook_count: data[index].value});
- }
- }
- },
- (err) => {
- this.setState({serverError: err.message});
- }
- );
- }
- }
-
- componentWillReceiveProps() {
- this.setState({
- serverError: null,
- channel_open_count: null,
- channel_private_count: null,
- post_count: null,
- post_counts_day: null,
- user_counts_with_posts_day: null,
- unique_user_count: null
- });
-
- this.getData();
- }
-
- render() {
- return (
- <div>
- <Analytics
- intl={this.props.intl}
- title={this.props.intl.formatMessage(labels.title)}
- channelOpenCount={this.state.channel_open_count}
- channelPrivateCount={this.state.channel_private_count}
- postCount={this.state.post_count}
- showAdvanced={global.window.mm_license.IsLicensed === 'true'}
- filePostCount={this.state.file_post_count}
- hashtagPostCount={this.state.hashtag_post_count}
- incomingWebhookCount={this.state.incoming_webhook_count}
- outgoingWebhookCount={this.state.outgoing_webhook_count}
- postCountsDay={this.state.post_counts_day}
- userCountsWithPostsDay={this.state.user_counts_with_posts_day}
- uniqueUserCount={this.state.unique_user_count}
- serverError={this.state.serverError}
- />
- </div>
- );
- }
-}
-
-SystemAnalytics.propTypes = {
- intl: intlShape.isRequired,
- team: React.PropTypes.object
-};
-
-export default injectIntl(SystemAnalytics);
diff --git a/web/react/components/admin_console/team_analytics.jsx b/web/react/components/admin_console/team_analytics.jsx
deleted file mode 100644
index 808d8046d..000000000
--- a/web/react/components/admin_console/team_analytics.jsx
+++ /dev/null
@@ -1,253 +0,0 @@
-// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-import Analytics from './analytics.jsx';
-import * as Client from '../../utils/client.jsx';
-
-import {injectIntl, intlShape, defineMessages} from 'mm-intl';
-
-const labels = defineMessages({
- totalPosts: {
- id: 'admin.team_analytics.totalPosts',
- defaultMessage: 'Total Posts'
- },
- activeUsers: {
- id: 'admin.team_analytics.activeUsers',
- defaultMessage: 'Active Users With Posts'
- }
-});
-
-class TeamAnalytics extends React.Component {
- constructor(props) {
- super(props);
-
- this.getData = this.getData.bind(this);
-
- this.state = { // most of this state should be from a store in the future
- users: null,
- serverError: null,
- channel_open_count: null,
- channel_private_count: null,
- post_count: null,
- post_counts_day: null,
- user_counts_with_posts_day: null,
- recent_active_users: null,
- newly_created_users: null,
- unique_user_count: null
- };
- }
-
- componentDidMount() {
- this.getData(this.props.team.id);
- }
-
- getData(teamId) { // should be moved to an action creator eventually
- const {formatMessage} = this.props.intl;
- Client.getTeamAnalytics(
- teamId,
- 'standard',
- (data) => {
- for (var index in data) {
- if (data[index].name === 'channel_open_count') {
- this.setState({channel_open_count: data[index].value});
- }
-
- if (data[index].name === 'channel_private_count') {
- this.setState({channel_private_count: data[index].value});
- }
-
- if (data[index].name === 'post_count') {
- this.setState({post_count: data[index].value});
- }
-
- if (data[index].name === 'unique_user_count') {
- this.setState({unique_user_count: data[index].value});
- }
- }
- },
- (err) => {
- this.setState({serverError: err.message});
- }
- );
-
- Client.getTeamAnalytics(
- teamId,
- 'post_counts_day',
- (data) => {
- data.reverse();
-
- var chartData = {
- labels: [],
- datasets: [{
- label: formatMessage(labels.totalPosts),
- fillColor: 'rgba(151,187,205,0.2)',
- strokeColor: 'rgba(151,187,205,1)',
- pointColor: 'rgba(151,187,205,1)',
- pointStrokeColor: '#fff',
- pointHighlightFill: '#fff',
- pointHighlightStroke: 'rgba(151,187,205,1)',
- data: []
- }]
- };
-
- for (var index in data) {
- if (data[index]) {
- var row = data[index];
- chartData.labels.push(row.name);
- chartData.datasets[0].data.push(row.value);
- }
- }
-
- this.setState({post_counts_day: chartData});
- },
- (err) => {
- this.setState({serverError: err.message});
- }
- );
-
- Client.getTeamAnalytics(
- teamId,
- 'user_counts_with_posts_day',
- (data) => {
- data.reverse();
-
- var chartData = {
- labels: [],
- datasets: [{
- label: formatMessage(labels.activeUsers),
- fillColor: 'rgba(151,187,205,0.2)',
- strokeColor: 'rgba(151,187,205,1)',
- pointColor: 'rgba(151,187,205,1)',
- pointStrokeColor: '#fff',
- pointHighlightFill: '#fff',
- pointHighlightStroke: 'rgba(151,187,205,1)',
- data: []
- }]
- };
-
- for (var index in data) {
- if (data[index]) {
- var row = data[index];
- chartData.labels.push(row.name);
- chartData.datasets[0].data.push(row.value);
- }
- }
-
- this.setState({user_counts_with_posts_day: chartData});
- },
- (err) => {
- this.setState({serverError: err.message});
- }
- );
-
- Client.getProfilesForTeam(
- teamId,
- (users) => {
- this.setState({users});
-
- var usersList = [];
- for (var id in users) {
- if (users.hasOwnProperty(id)) {
- usersList.push(users[id]);
- }
- }
-
- usersList.sort((a, b) => {
- if (a.last_activity_at < b.last_activity_at) {
- return 1;
- }
-
- if (a.last_activity_at > b.last_activity_at) {
- return -1;
- }
-
- return 0;
- });
-
- var recentActive = [];
- for (let i = 0; i < usersList.length; i++) {
- if (usersList[i].last_activity_at == null) {
- continue;
- }
-
- recentActive.push(usersList[i]);
- if (i > 19) {
- break;
- }
- }
-
- this.setState({recent_active_users: recentActive});
-
- usersList.sort((a, b) => {
- if (a.create_at < b.create_at) {
- return 1;
- }
-
- if (a.create_at > b.create_at) {
- return -1;
- }
-
- return 0;
- });
-
- var newlyCreated = [];
- for (let i = 0; i < usersList.length; i++) {
- newlyCreated.push(usersList[i]);
- if (i > 19) {
- break;
- }
- }
-
- this.setState({newly_created_users: newlyCreated});
- },
- (err) => {
- this.setState({serverError: err.message});
- }
- );
- }
-
- componentWillReceiveProps(newProps) {
- this.setState({
- users: null,
- serverError: null,
- channel_open_count: null,
- channel_private_count: null,
- post_count: null,
- post_counts_day: null,
- user_counts_with_posts_day: null,
- recent_active_users: null,
- newly_created_users: null,
- unique_user_count: null
- });
-
- this.getData(newProps.team.id);
- }
-
- render() {
- return (
- <div>
- <Analytics
- intl={this.props.intl}
- title={this.props.team.name}
- users={this.state.users}
- channelOpenCount={this.state.channel_open_count}
- channelPrivateCount={this.state.channel_private_count}
- postCount={this.state.post_count}
- postCountsDay={this.state.post_counts_day}
- userCountsWithPostsDay={this.state.user_counts_with_posts_day}
- recentActiveUsers={this.state.recent_active_users}
- newlyCreatedUsers={this.state.newly_created_users}
- uniqueUserCount={this.state.unique_user_count}
- serverError={this.state.serverError}
- />
- </div>
- );
- }
-}
-
-TeamAnalytics.propTypes = {
- intl: intlShape.isRequired,
- team: React.PropTypes.object
-};
-
-export default injectIntl(TeamAnalytics);
diff --git a/web/react/components/admin_console/user_item.jsx b/web/react/components/admin_console/user_item.jsx
index 009a9f004..4af350bcd 100644
--- a/web/react/components/admin_console/user_item.jsx
+++ b/web/react/components/admin_console/user_item.jsx
@@ -7,26 +7,7 @@ import UserStore from '../../stores/user_store.jsx';
import ConfirmModal from '../confirm_modal.jsx';
import TeamStore from '../../stores/team_store.jsx';
-import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'mm-intl';
-
-var holders = defineMessages({
- confirmDemoteRoleTitle: {
- id: 'admin.user_item.confirmDemoteRoleTitle',
- defaultMessage: 'Confirm demotion from System Admin role'
- },
- confirmDemotion: {
- id: 'admin.user_item.confirmDemotion',
- defaultMessage: 'Confirm Demotion'
- },
- confirmDemoteDescription: {
- id: 'admin.user_item.confirmDemoteDescription',
- defaultMessage: 'If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you\'ll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command.'
- },
- confirmDemotionCmd: {
- id: 'admin.user_item.confirmDemotionCmd',
- defaultMessage: 'platform -assign_role -team_name="yourteam" -email="name@yourcompany.com" -role="system_admin"'
- }
-});
+import {FormattedMessage} from 'mm-intl';
export default class UserItem extends React.Component {
constructor(props) {
@@ -336,15 +317,44 @@ export default class UserItem extends React.Component {
);
}
const me = UserStore.getCurrentUser();
- const {formatMessage} = this.props.intl;
let makeDemoteModal = null;
if (this.props.user.id === me.id) {
+ const title = (
+ <FormattedMessage
+ id='admin.user_item.confirmDemoteRoleTitle'
+ defaultMessage='Confirm demotion from System Admin role'
+ />
+ );
+
+ const message = (
+ <div>
+ <FormattedMessage
+ id='admin.user_item.confirmDemoteDescription'
+ defaultMessage="If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you\'ll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command."
+ />
+ <br/>
+ <br/>
+ <FormattedMessage
+ id='admin.user_item.confirmDemotionCmd'
+ defaultMessage='platform -assign_role -team_name="yourteam" -email="name@yourcompany.com" -role="system_admin"'
+ />
+ {serverError}
+ </div>
+ );
+
+ const confirmButton = (
+ <FormattedMessage
+ id='admin.user_item.confirmDemotion'
+ defaultMessage='Confirm Demotion'
+ />
+ );
+
makeDemoteModal = (
<ConfirmModal
show={this.state.showDemoteModal}
- title={formatMessage(holders.confirmDemoteRoleTitle)}
- message={[formatMessage(holders.confirmDemoteDescription), React.createElement('br'), React.createElement('br'), formatMessage(holders.confirmDemotionCmd), serverError]}
- confirm_button={formatMessage(holders.confirmDemotion)}
+ title={title}
+ message={message}
+ confirmButton={confirmButton}
onConfirm={this.handleDemoteSubmit}
onCancel={this.handleDemoteCancel}
/>
@@ -405,10 +415,7 @@ export default class UserItem extends React.Component {
}
UserItem.propTypes = {
- intl: intlShape.isRequired,
user: React.PropTypes.object.isRequired,
refreshProfiles: React.PropTypes.func.isRequired,
doPasswordReset: React.PropTypes.func.isRequired
};
-
-export default injectIntl(UserItem);
diff --git a/web/react/components/admin_console/doughnut_chart.jsx b/web/react/components/analytics/doughnut_chart.jsx
index e2dc01528..00bb66f0a 100644
--- a/web/react/components/admin_console/doughnut_chart.jsx
+++ b/web/react/components/analytics/doughnut_chart.jsx
@@ -39,7 +39,7 @@ export default class DoughnutChart extends React.Component {
if (this.props.data == null) {
content = (
<FormattedMessage
- id='admin.analytics.loading'
+ id='analytics.chart.loading'
defaultMessage='Loading...'
/>
);
@@ -69,7 +69,7 @@ export default class DoughnutChart extends React.Component {
}
DoughnutChart.propTypes = {
- title: React.PropTypes.string,
+ title: React.PropTypes.node,
width: React.PropTypes.string,
height: React.PropTypes.string,
data: React.PropTypes.array,
diff --git a/web/react/components/analytics/line_chart.jsx b/web/react/components/analytics/line_chart.jsx
new file mode 100644
index 000000000..d1bb6b9cb
--- /dev/null
+++ b/web/react/components/analytics/line_chart.jsx
@@ -0,0 +1,90 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {FormattedMessage} from 'mm-intl';
+
+export default class LineChart extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.initChart = this.initChart.bind(this);
+ this.chart = null;
+ }
+
+ componentDidMount() {
+ this.initChart();
+ }
+
+ componentDidUpdate() {
+ if (this.chart) {
+ this.chart.destroy();
+ }
+ this.initChart();
+ }
+
+ componentWillUnmount() {
+ if (this.chart) {
+ this.chart.destroy();
+ }
+ }
+
+ initChart() {
+ if (!this.refs.canvas) {
+ return;
+ }
+ var el = ReactDOM.findDOMNode(this.refs.canvas);
+ var ctx = el.getContext('2d');
+ this.chart = new Chart(ctx).Line(this.props.data, this.props.options || {}); //eslint-disable-line new-cap
+ }
+
+ render() {
+ let content;
+ if (this.props.data == null) {
+ content = (
+ <FormattedMessage
+ id='analytics.chart.loading'
+ defaultMessage='Loading...'
+ />
+ );
+ } else if (this.props.data.labels.length === 0) {
+ content = (
+ <h5>
+ <FormattedMessage
+ id='analytics.chart.meaningful'
+ defaultMessage='Not enough data for a meaningful representation.'
+ />
+ </h5>
+ );
+ } else {
+ content = (
+ <canvas
+ ref='canvas'
+ width={this.props.width}
+ height={this.props.height}
+ />
+ );
+ }
+
+ return (
+ <div className='col-sm-12'>
+ <div className='total-count by-day'>
+ <div className='title'>
+ {this.props.title}
+ </div>
+ <div className='content'>
+ {content}
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+LineChart.propTypes = {
+ title: React.PropTypes.node.isRequired,
+ width: React.PropTypes.string.isRequired,
+ height: React.PropTypes.string.isRequired,
+ data: React.PropTypes.object,
+ options: React.PropTypes.object
+};
+
diff --git a/web/react/components/admin_console/statistic_count.jsx b/web/react/components/analytics/statistic_count.jsx
index 118a0ad31..cf457310f 100644
--- a/web/react/components/admin_console/statistic_count.jsx
+++ b/web/react/components/analytics/statistic_count.jsx
@@ -7,7 +7,7 @@ export default class StatisticCount extends React.Component {
render() {
let loading = (
<FormattedMessage
- id='admin.analytics.loading'
+ id='analytics.chart.loading'
defaultMessage='Loading...'
/>
);
@@ -27,7 +27,7 @@ export default class StatisticCount extends React.Component {
}
StatisticCount.propTypes = {
- title: React.PropTypes.string.isRequired,
+ title: React.PropTypes.node.isRequired,
icon: React.PropTypes.string.isRequired,
count: React.PropTypes.number
};
diff --git a/web/react/components/analytics/system_analytics.jsx b/web/react/components/analytics/system_analytics.jsx
new file mode 100644
index 000000000..a2b783a79
--- /dev/null
+++ b/web/react/components/analytics/system_analytics.jsx
@@ -0,0 +1,346 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import LineChart from './line_chart.jsx';
+import DoughnutChart from './doughnut_chart.jsx';
+import StatisticCount from './statistic_count.jsx';
+
+import AnalyticsStore from '../../stores/analytics_store.jsx';
+
+import * as Utils from '../../utils/utils.jsx';
+import * as AsyncClient from '../../utils/async_client.jsx';
+import Constants from '../../utils/constants.jsx';
+const StatTypes = Constants.StatTypes;
+
+import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'mm-intl';
+
+const holders = defineMessages({
+ analyticsPublicChannels: {
+ id: 'analytics.system.publicChannels',
+ defaultMessage: 'Public Channels'
+ },
+ analyticsPrivateGroups: {
+ id: 'analytics.system.privateGroups',
+ defaultMessage: 'Private Groups'
+ },
+ analyticsFilePosts: {
+ id: 'analytics.system.totalFilePosts',
+ defaultMessage: 'Posts with Files'
+ },
+ analyticsHashtagPosts: {
+ id: 'analytics.system.totalHashtagPosts',
+ defaultMessage: 'Posts with Hashtags'
+ },
+ analyticsTextPosts: {
+ id: 'analytics.system.textPosts',
+ defaultMessage: 'Posts with Text-only'
+ }
+});
+
+class SystemAnalytics extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.onChange = this.onChange.bind(this);
+
+ this.state = {stats: AnalyticsStore.getAllSystem()};
+ }
+
+ componentDidMount() {
+ AnalyticsStore.addChangeListener(this.onChange);
+
+ AsyncClient.getStandardAnalytics();
+ AsyncClient.getPostsPerDayAnalytics();
+ AsyncClient.getUsersPerDayAnalytics();
+
+ if (global.window.mm_license.IsLicensed === 'true') {
+ AsyncClient.getAdvancedAnalytics();
+ }
+ }
+
+ componentWillUnmount() {
+ AnalyticsStore.removeChangeListener(this.onChange);
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ if (!Utils.areObjectsEqual(nextState.stats, this.state.stats)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ onChange() {
+ this.setState({stats: AnalyticsStore.getAllSystem()});
+ }
+
+ render() {
+ const stats = this.state.stats;
+
+ let advancedCounts;
+ let advancedGraphs;
+ if (global.window.mm_license.IsLicensed === 'true') {
+ advancedCounts = (
+ <div className='row'>
+ <StatisticCount
+ title={
+ <FormattedMessage
+ id='analytics.system.totalSessions'
+ defaultMessage='Total Sessions'
+ />
+ }
+ icon='fa-signal'
+ count={stats[StatTypes.TOTAL_SESSIONS]}
+ />
+ <StatisticCount
+ title={
+ <FormattedMessage
+ id='analytics.system.totalCommands'
+ defaultMessage='Total Commands'
+ />
+ }
+ icon='fa-terminal'
+ count={stats[StatTypes.TOTAL_COMMANDS]}
+ />
+ <StatisticCount
+ title={
+ <FormattedMessage
+ id='analytics.system.totalIncomingWebhooks'
+ defaultMessage='Incoming Webhooks'
+ />
+ }
+ icon='fa-arrow-down'
+ count={stats[StatTypes.TOTAL_IHOOKS]}
+ />
+ <StatisticCount
+ title={
+ <FormattedMessage
+ id='analytics.system.totalOutgoingWebhooks'
+ defaultMessage='Outgoing Webhooks'
+ />
+ }
+ icon='fa-arrow-up'
+ count={stats[StatTypes.TOTAL_OHOOKS]}
+ />
+ </div>
+ );
+
+ const channelTypeData = formatChannelDoughtnutData(stats[StatTypes.TOTAL_PUBLIC_CHANNELS], stats[StatTypes.TOTAL_PRIVATE_GROUPS], this.props.intl);
+ const postTypeData = formatPostDoughtnutData(stats[StatTypes.TOTAL_FILE_POSTS], stats[StatTypes.TOTAL_HASHTAG_POSTS], stats[StatTypes.TOTAL_POSTS], this.props.intl);
+
+ advancedGraphs = (
+ <div className='row'>
+ <DoughnutChart
+ title={
+ <FormattedMessage
+ id='analytics.system.channelTypes'
+ defaultMessage='Channel Types'
+ />
+ }
+ data={channelTypeData}
+ width='300'
+ height='225'
+ />
+ <DoughnutChart
+ title={
+ <FormattedMessage
+ id='analytics.system.postTypes'
+ defaultMessage='Posts, Files and Hashtags'
+ />
+ }
+ data={postTypeData}
+ width='300'
+ height='225'
+ />
+ </div>
+ );
+ }
+
+ const postCountsDay = formatPostsPerDayData(stats[StatTypes.POST_PER_DAY]);
+ const userCountsWithPostsDay = formatUsersWithPostsPerDayData(stats[StatTypes.USERS_WITH_POSTS_PER_DAY]);
+
+ return (
+ <div className='wrapper--fixed team_statistics'>
+ <h3>
+ <FormattedMessage
+ id='analytics.system.title'
+ defaultMessage='System Statistics'
+ />
+ </h3>
+ <div className='row'>
+ <StatisticCount
+ title={
+ <FormattedMessage
+ id='analytics.system.totalUsers'
+ defaultMessage='Total Users'
+ />
+ }
+ icon='fa-user'
+ count={stats[StatTypes.TOTAL_USERS]}
+ />
+ <StatisticCount
+ title={
+ <FormattedMessage
+ id='analytics.system.totalTeams'
+ defaultMessage='Total Teams'
+ />
+ }
+ icon='fa-users'
+ count={stats[StatTypes.TOTAL_TEAMS]}
+ />
+ <StatisticCount
+ title={
+ <FormattedMessage
+ id='analytics.system.totalPosts'
+ defaultMessage='Total Posts'
+ />
+ }
+ icon='fa-comment'
+ count={stats[StatTypes.TOTAL_POSTS]}
+ />
+ <StatisticCount
+ title={
+ <FormattedMessage
+ id='analytics.system.totalChannels'
+ defaultMessage='Total Channels'
+ />
+ }
+ icon='fa-globe'
+ count={stats[StatTypes.TOTAL_PUBLIC_CHANNELS] + stats[StatTypes.TOTAL_PRIVATE_GROUPS]}
+ />
+ </div>
+ {advancedCounts}
+ {advancedGraphs}
+ <div className='row'>
+ <LineChart
+ title={
+ <FormattedMessage
+ id='analytics.system.totalPosts'
+ defaultMessage='Total Posts'
+ />
+ }
+ data={postCountsDay}
+ width='740'
+ height='225'
+ />
+ </div>
+ <div className='row'>
+ <LineChart
+ title={
+ <FormattedMessage
+ id='analytics.system.activeUsers'
+ defaultMessage='Active Users With Posts'
+ />
+ }
+ data={userCountsWithPostsDay}
+ width='740'
+ height='225'
+ />
+ </div>
+ </div>
+ );
+ }
+}
+
+SystemAnalytics.propTypes = {
+ intl: intlShape.isRequired,
+ team: React.PropTypes.object
+};
+
+export default injectIntl(SystemAnalytics);
+
+export function formatChannelDoughtnutData(totalPublic, totalPrivate, intl) {
+ const {formatMessage} = intl;
+ const channelTypeData = [
+ {
+ value: totalPublic,
+ color: '#46BFBD',
+ highlight: '#5AD3D1',
+ label: formatMessage(holders.analyticsPublicChannels)
+ },
+ {
+ value: totalPrivate,
+ color: '#FDB45C',
+ highlight: '#FFC870',
+ label: formatMessage(holders.analyticsPrivateGroups)
+ }
+ ];
+
+ return channelTypeData;
+}
+
+export function formatPostDoughtnutData(filePosts, hashtagPosts, totalPosts, intl) {
+ const {formatMessage} = intl;
+ const postTypeData = [
+ {
+ value: filePosts,
+ color: '#46BFBD',
+ highlight: '#5AD3D1',
+ label: formatMessage(holders.analyticsFilePosts)
+ },
+ {
+ value: hashtagPosts,
+ color: '#F7464A',
+ highlight: '#FF5A5E',
+ label: formatMessage(holders.analyticsHashtagPosts)
+ },
+ {
+ value: totalPosts - filePosts - hashtagPosts,
+ color: '#FDB45C',
+ highlight: '#FFC870',
+ label: formatMessage(holders.analyticsTextPosts)
+ }
+ ];
+
+ return postTypeData;
+}
+
+export function formatPostsPerDayData(data) {
+ var chartData = {
+ labels: [],
+ datasets: [{
+ fillColor: 'rgba(151,187,205,0.2)',
+ strokeColor: 'rgba(151,187,205,1)',
+ pointColor: 'rgba(151,187,205,1)',
+ pointStrokeColor: '#fff',
+ pointHighlightFill: '#fff',
+ pointHighlightStroke: 'rgba(151,187,205,1)',
+ data: []
+ }]
+ };
+
+ for (var index in data) {
+ if (data[index]) {
+ var row = data[index];
+ chartData.labels.push(row.name);
+ chartData.datasets[0].data.push(row.value);
+ }
+ }
+
+ return chartData;
+}
+
+export function formatUsersWithPostsPerDayData(data) {
+ var chartData = {
+ labels: [],
+ datasets: [{
+ fillColor: 'rgba(151,187,205,0.2)',
+ strokeColor: 'rgba(151,187,205,1)',
+ pointColor: 'rgba(151,187,205,1)',
+ pointStrokeColor: '#fff',
+ pointHighlightFill: '#fff',
+ pointHighlightStroke: 'rgba(151,187,205,1)',
+ data: []
+ }]
+ };
+
+ for (var index in data) {
+ if (data[index]) {
+ var row = data[index];
+ chartData.labels.push(row.name);
+ chartData.datasets[0].data.push(row.value);
+ }
+ }
+
+ return chartData;
+}
diff --git a/web/react/components/analytics/table_chart.jsx b/web/react/components/analytics/table_chart.jsx
new file mode 100644
index 000000000..c94fa300b
--- /dev/null
+++ b/web/react/components/analytics/table_chart.jsx
@@ -0,0 +1,60 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import Constants from '../../utils/constants.jsx';
+
+const Tooltip = ReactBootstrap.Tooltip;
+const OverlayTrigger = ReactBootstrap.OverlayTrigger;
+
+export default class TableChart extends React.Component {
+ render() {
+ return (
+ <div className='col-sm-6'>
+ <div className='total-count recent-active-users'>
+ <div className='title'>
+ {this.props.title}
+ </div>
+ <div className='content'>
+ <table>
+ <tbody>
+ {
+ this.props.data.map((item) => {
+ const tooltip = (
+ <Tooltip id={'tip-table-entry-' + item.name}>
+ {item.tip}
+ </Tooltip>
+ );
+
+ return (
+ <tr key={'table-entry-' + item.name}>
+ <td>
+ <OverlayTrigger
+ delayShow={Constants.OVERLAY_TIME_DELAY}
+ placement='top'
+ overlay={tooltip}
+ >
+ <time>
+ {item.name}
+ </time>
+ </OverlayTrigger>
+ </td>
+ <td>
+ {item.value}
+ </td>
+ </tr>
+ );
+ })
+ }
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+TableChart.propTypes = {
+ title: React.PropTypes.node,
+ data: React.PropTypes.array
+};
diff --git a/web/react/components/analytics/team_analytics.jsx b/web/react/components/analytics/team_analytics.jsx
new file mode 100644
index 000000000..1236c070b
--- /dev/null
+++ b/web/react/components/analytics/team_analytics.jsx
@@ -0,0 +1,235 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import LineChart from './line_chart.jsx';
+import StatisticCount from './statistic_count.jsx';
+import TableChart from './table_chart.jsx';
+
+import AnalyticsStore from '../../stores/analytics_store.jsx';
+
+import * as Utils from '../../utils/utils.jsx';
+import * as AsyncClient from '../../utils/async_client.jsx';
+import Constants from '../../utils/constants.jsx';
+const StatTypes = Constants.StatTypes;
+
+import {formatPostsPerDayData, formatUsersWithPostsPerDayData} from './system_analytics.jsx';
+import {injectIntl, intlShape, FormattedMessage, FormattedDate} from 'mm-intl';
+
+class TeamAnalytics extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.onChange = this.onChange.bind(this);
+
+ this.state = {stats: AnalyticsStore.getAllTeam(this.props.team.id)};
+ }
+
+ componentDidMount() {
+ AnalyticsStore.addChangeListener(this.onChange);
+
+ this.getData(this.props.team.id);
+ }
+
+ getData(id) {
+ AsyncClient.getStandardAnalytics(id);
+ AsyncClient.getPostsPerDayAnalytics(id);
+ AsyncClient.getUsersPerDayAnalytics(id);
+ AsyncClient.getRecentAndNewUsersAnalytics(id);
+ }
+
+ componentWillUnmount() {
+ AnalyticsStore.removeChangeListener(this.onChange);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.getData(nextProps.team.id);
+ this.setState({stats: AnalyticsStore.getAllTeam(nextProps.team.id)});
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ if (!Utils.areObjectsEqual(nextState.stats, this.state.stats)) {
+ return true;
+ }
+
+ if (!Utils.areObjectsEqual(nextProps.team, this.props.team)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ onChange() {
+ this.setState({stats: AnalyticsStore.getAllTeam(this.props.team.id)});
+ }
+
+ render() {
+ const stats = this.state.stats;
+ const postCountsDay = formatPostsPerDayData(stats[StatTypes.POST_PER_DAY]);
+ const userCountsWithPostsDay = formatUsersWithPostsPerDayData(stats[StatTypes.USERS_WITH_POSTS_PER_DAY]);
+ const recentActiveUsers = formatRecentUsersData(stats[StatTypes.RECENTLY_ACTIVE_USERS]);
+ const newlyCreatedUsers = formatNewUsersData(stats[StatTypes.NEWLY_CREATED_USERS]);
+
+ return (
+ <div className='wrapper--fixed team_statistics'>
+ <h3>
+ <FormattedMessage
+ id='analytics.team.title'
+ defaultMessage='Team Statistics for {team}'
+ values={{
+ team: this.props.team.name
+ }}
+ />
+ </h3>
+ <div className='row'>
+ <StatisticCount
+ title={
+ <FormattedMessage
+ id='analytics.team.totalUsers'
+ defaultMessage='Total Users'
+ />
+ }
+ icon='fa-user'
+ count={stats[StatTypes.TOTAL_USERS]}
+ />
+ <StatisticCount
+ title={
+ <FormattedMessage
+ id='analytics.team.publicChannels'
+ defaultMessage='Public Channels'
+ />
+ }
+ icon='fa-users'
+ count={stats[StatTypes.TOTAL_PUBLIC_CHANNELS]}
+ />
+ <StatisticCount
+ title={
+ <FormattedMessage
+ id='analytics.team.privateGroups'
+ defaultMessage='Private Groups'
+ />
+ }
+ icon='fa-globe'
+ count={stats[StatTypes.TOTAL_PRIVATE_GROUPS]}
+ />
+ <StatisticCount
+ title={
+ <FormattedMessage
+ id='analytics.team.totalPosts'
+ defaultMessage='Total Posts'
+ />
+ }
+ icon='fa-comment'
+ count={stats[StatTypes.TOTAL_POSTS]}
+ />
+ </div>
+ <div className='row'>
+ <LineChart
+ title={
+ <FormattedMessage
+ id='analytics.team.totalPosts'
+ defaultMessage='Total Posts'
+ />
+ }
+ data={postCountsDay}
+ width='740'
+ height='225'
+ />
+ </div>
+ <div className='row'>
+ <LineChart
+ title={
+ <FormattedMessage
+ id='analytics.team.activeUsers'
+ defaultMessage='Active Users With Posts'
+ />
+ }
+ data={userCountsWithPostsDay}
+ width='740'
+ height='225'
+ />
+ </div>
+ <div className='row'>
+ <TableChart
+ title={
+ <FormattedMessage
+ id='analytics.team.activeUsers'
+ defaultMessage='Recent Active Users'
+ />
+ }
+ data={recentActiveUsers}
+ />
+ <TableChart
+ title={
+ <FormattedMessage
+ id='analytics.team.newlyCreated'
+ defaultMessage='Newly Created Users'
+ />
+ }
+ data={newlyCreatedUsers}
+ />
+ </div>
+ </div>
+ );
+ }
+}
+
+TeamAnalytics.propTypes = {
+ intl: intlShape.isRequired,
+ team: React.PropTypes.object.isRequired
+};
+
+export default injectIntl(TeamAnalytics);
+
+export function formatRecentUsersData(data) {
+ if (data == null) {
+ return [];
+ }
+
+ const formattedData = data.map((user) => {
+ const item = {};
+ item.name = user.username;
+ item.value = (
+ <FormattedDate
+ value={user.last_activity_at}
+ day='numeric'
+ month='long'
+ year='numeric'
+ hour12={true}
+ hour='2-digit'
+ minute='2-digit'
+ />
+ );
+ item.tip = user.email;
+
+ return item;
+ });
+
+ return formattedData;
+}
+
+export function formatNewUsersData(data) {
+ if (data == null) {
+ return [];
+ }
+
+ const formattedData = data.map((user) => {
+ const item = {};
+ item.name = user.username;
+ item.value = (
+ <FormattedDate
+ value={user.create_at}
+ day='numeric'
+ month='long'
+ year='numeric'
+ hour12={true}
+ hour='2-digit'
+ minute='2-digit'
+ />
+ );
+ item.tip = user.email;
+
+ return item;
+ });
+
+ return formattedData;
+}
diff --git a/web/react/components/channel_invite_modal.jsx b/web/react/components/channel_invite_modal.jsx
index c9fe871d0..6c8d51abb 100644
--- a/web/react/components/channel_invite_modal.jsx
+++ b/web/react/components/channel_invite_modal.jsx
@@ -1,7 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import MemberList from './member_list.jsx';
+import FilteredUserList from './filtered_user_list.jsx';
import LoadingScreen from './loading_screen.jsx';
import UserStore from '../stores/user_store.jsx';
@@ -22,8 +22,12 @@ export default class ChannelInviteModal extends React.Component {
this.onListenerChange = this.onListenerChange.bind(this);
this.handleInvite = this.handleInvite.bind(this);
+ this.createInviteButton = this.createInviteButton.bind(this);
+
// the state gets populated when the modal is shown
- this.state = {};
+ this.state = {
+ loading: true
+ };
}
shouldComponentUpdate(nextProps, nextState) {
if (!this.props.show && !nextProps.show) {
@@ -77,19 +81,6 @@ export default class ChannelInviteModal extends React.Component {
loading: false
};
}
- onShow() {
- if ($(window).width() > 768) {
- $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar();
- $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200);
- } else {
- $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 150);
- }
- }
- componentDidUpdate(prevProps) {
- if (this.props.show && !prevProps.show) {
- this.onShow();
- }
- }
componentWillReceiveProps(nextProps) {
if (!this.props.show && nextProps.show) {
ChannelStore.addExtraInfoChangeListener(this.onListenerChange);
@@ -108,9 +99,10 @@ export default class ChannelInviteModal extends React.Component {
this.setState(newState);
}
}
- handleInvite(userId) {
- var data = {};
- data.user_id = userId;
+ handleInvite(user) {
+ const data = {
+ user_id: user.id
+ };
Client.addChannelMember(
this.props.channel.id,
@@ -124,27 +116,40 @@ export default class ChannelInviteModal extends React.Component {
}
);
}
+ createInviteButton({user}) {
+ return (
+ <a
+ onClick={this.handleInvite.bind(this, user)}
+ className='btn btn-sm btn-primary'
+ >
+ <i className='glyphicon glyphicon-envelope'/>
+ <FormattedMessage
+ id='channel_invite.add'
+ defaultMessage=' Add'
+ />
+ </a>
+ );
+ }
render() {
var inviteError = null;
if (this.state.inviteError) {
inviteError = (<label className='has-error control-label'>{this.state.inviteError}</label>);
}
- var currentMember = ChannelStore.getCurrentMember();
- var isAdmin = false;
- if (currentMember) {
- isAdmin = Utils.isAdmin(currentMember.roles) || Utils.isAdmin(UserStore.getCurrentUser().roles);
- }
-
var content;
if (this.state.loading) {
content = (<LoadingScreen/>);
} else {
+ let maxHeight = 1000;
+ if (Utils.windowHeight() <= 1200) {
+ maxHeight = Utils.windowHeight() - 300;
+ }
+
content = (
- <MemberList
- memberList={this.state.nonmembers}
- isAdmin={isAdmin}
- handleInvite={this.handleInvite}
+ <FilteredUserList
+ style={{maxHeight}}
+ users={this.state.nonmembers}
+ actions={[this.createInviteButton]}
/>
);
}
@@ -164,9 +169,7 @@ export default class ChannelInviteModal extends React.Component {
<span className='name'>{this.props.channel.display_name}</span>
</Modal.Title>
</Modal.Header>
- <Modal.Body
- ref='modalBody'
- >
+ <Modal.Body>
{inviteError}
{content}
</Modal.Body>
diff --git a/web/react/components/channel_members_modal.jsx b/web/react/components/channel_members_modal.jsx
index fd452f206..688ab7dd2 100644
--- a/web/react/components/channel_members_modal.jsx
+++ b/web/react/components/channel_members_modal.jsx
@@ -1,8 +1,8 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import FilteredUserList from './filtered_user_list.jsx';
import LoadingScreen from './loading_screen.jsx';
-import MemberList from './member_list.jsx';
import ChannelInviteModal from './channel_invite_modal.jsx';
import UserStore from '../stores/user_store.jsx';
@@ -24,6 +24,8 @@ export default class ChannelMembersModal extends React.Component {
this.onChange = this.onChange.bind(this);
this.handleRemove = this.handleRemove.bind(this);
+ this.createRemoveMemberButton = this.createRemoveMemberButton.bind(this);
+
// the rest of the state gets populated when the modal is shown
this.state = {
showInviteModal: false
@@ -51,24 +53,10 @@ export default class ChannelMembersModal extends React.Component {
};
}
- const users = UserStore.getActiveOnlyProfiles();
- const memberList = extraInfo.members;
-
- const nonmemberList = [];
- for (const id in users) {
- if (users.hasOwnProperty(id)) {
- let found = false;
- for (let i = 0; i < memberList.length; i++) {
- if (memberList[i].id === id) {
- found = true;
- break;
- }
- }
- if (!found) {
- nonmemberList.push(users[id]);
- }
- }
- }
+ // clone the member list since we mutate it later on
+ const memberList = extraInfo.members.map((member) => {
+ return Object.assign({}, member);
+ });
function compareByUsername(a, b) {
if (a.username < b.username) {
@@ -81,24 +69,12 @@ export default class ChannelMembersModal extends React.Component {
}
memberList.sort(compareByUsername);
- nonmemberList.sort(compareByUsername);
return {
- nonmemberList,
memberList,
loading: false
};
}
- onShow() {
- if ($(window).width() > 768) {
- $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar();
- }
- }
- componentDidUpdate(prevProps) {
- if (this.props.show && !prevProps.show) {
- this.onShow();
- }
- }
componentWillReceiveProps(nextProps) {
if (!this.props.show && nextProps.show) {
ChannelStore.addExtraInfoChangeListener(this.onChange);
@@ -116,41 +92,25 @@ export default class ChannelMembersModal extends React.Component {
this.setState(newState);
}
}
- handleRemove(userId) {
- // Make sure the user is a member of the channel
- const memberList = this.state.memberList;
- let found = false;
- for (let i = 0; i < memberList.length; i++) {
- if (memberList[i].id === userId) {
- found = true;
- break;
- }
- }
-
- if (!found) {
- return;
- }
+ handleRemove(user) {
+ const userId = user.id;
const data = {};
data.user_id = userId;
- Client.removeChannelMember(ChannelStore.getCurrentId(), data,
+ Client.removeChannelMember(
+ ChannelStore.getCurrentId(),
+ data,
() => {
- let oldMember;
+ const memberList = this.state.memberList.slice();
for (let i = 0; i < memberList.length; i++) {
if (userId === memberList[i].id) {
- oldMember = memberList[i];
memberList.splice(i, 1);
break;
}
}
- const nonmemberList = this.state.nonmemberList;
- if (oldMember) {
- nonmemberList.push(oldMember);
- }
-
- this.setState({memberList, nonmemberList});
+ this.setState({memberList});
AsyncClient.getChannelExtraInfo();
},
(err) => {
@@ -158,30 +118,40 @@ export default class ChannelMembersModal extends React.Component {
}
);
}
- render() {
- var maxHeight = 1000;
- if (Utils.windowHeight() <= 1200) {
- maxHeight = Utils.windowHeight() - 300;
- }
-
- const currentMember = ChannelStore.getCurrentMember();
- let isAdmin = false;
- if (currentMember) {
- isAdmin = Utils.isAdmin(currentMember.roles) || Utils.isAdmin(UserStore.getCurrentUser().roles);
+ createRemoveMemberButton({user}) {
+ if (user.id === UserStore.getCurrentId()) {
+ return null;
}
+ return (
+ <button
+ type='button'
+ className='btn btn-primary btn-message'
+ onClick={this.handleRemove.bind(this, user)}
+ >
+ <FormattedMessage
+ id='channel_members_modal.removeMember'
+ defaultMessage='Remove Member'
+ />
+ </button>
+ );
+ }
+ render() {
let content;
if (this.state.loading) {
content = (<LoadingScreen/>);
} else {
+ let maxHeight = 1000;
+ if (Utils.windowHeight() <= 1200) {
+ maxHeight = Utils.windowHeight() - 300;
+ }
+
content = (
- <div className='team-member-list'>
- <MemberList
- memberList={this.state.memberList}
- isAdmin={isAdmin}
- handleRemove={this.handleRemove}
- />
- </div>
+ <FilteredUserList
+ style={{maxHeight}}
+ users={this.state.memberList}
+ actions={[this.createRemoveMemberButton]}
+ />
);
}
@@ -217,7 +187,6 @@ export default class ChannelMembersModal extends React.Component {
</Modal.Header>
<Modal.Body
ref='modalBody'
- style={{maxHeight}}
>
{content}
</Modal.Body>
diff --git a/web/react/components/confirm_modal.jsx b/web/react/components/confirm_modal.jsx
index 987649f38..bb3576684 100644
--- a/web/react/components/confirm_modal.jsx
+++ b/web/react/components/confirm_modal.jsx
@@ -44,7 +44,7 @@ export default class ConfirmModal extends React.Component {
className='btn btn-primary'
onClick={this.props.onConfirm}
>
- {this.props.confirm_button}
+ {this.props.confirmButton}
</button>
</Modal.Footer>
</Modal>
@@ -55,13 +55,13 @@ export default class ConfirmModal extends React.Component {
ConfirmModal.defaultProps = {
title: '',
message: '',
- confirm_button: ''
+ confirmButton: ''
};
ConfirmModal.propTypes = {
show: React.PropTypes.bool.isRequired,
- title: React.PropTypes.string,
- message: React.PropTypes.string,
- confirm_button: React.PropTypes.string,
+ title: React.PropTypes.node,
+ message: React.PropTypes.node,
+ confirmButton: React.PropTypes.node,
onConfirm: React.PropTypes.func.isRequired,
onCancel: React.PropTypes.func.isRequired
};
diff --git a/web/react/components/filtered_user_list.jsx b/web/react/components/filtered_user_list.jsx
new file mode 100644
index 000000000..ffd6ebf53
--- /dev/null
+++ b/web/react/components/filtered_user_list.jsx
@@ -0,0 +1,136 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import UserList from './user_list.jsx';
+
+import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'mm-intl';
+
+const holders = defineMessages({
+ member: {
+ id: 'filtered_user_list.member',
+ defaultMessage: 'Member'
+ },
+ search: {
+ id: 'filtered_user_list.search',
+ defaultMessage: 'Search members'
+ }
+});
+
+class FilteredUserList extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleFilterChange = this.handleFilterChange.bind(this);
+
+ this.state = {
+ filter: ''
+ };
+ }
+
+ componentDidMount() {
+ $(ReactDOM.findDOMNode(this.refs.userList)).perfectScrollbar();
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevState.filter !== this.state.filter) {
+ $(ReactDOM.findDOMNode(this.refs.userList)).scrollTop(0);
+ }
+ }
+
+ handleFilterChange(e) {
+ this.setState({
+ filter: e.target.value
+ });
+ }
+
+ render() {
+ const {formatMessage} = this.props.intl;
+
+ let users = this.props.users;
+
+ if (this.state.filter) {
+ const filter = this.state.filter.toLowerCase();
+
+ users = users.filter((user) => {
+ return user.username.toLowerCase().indexOf(filter) !== -1 ||
+ (user.first_name && user.first_name.toLowerCase().indexOf(filter) !== -1) ||
+ (user.last_name && user.last_name.toLowerCase().indexOf(filter) !== -1) ||
+ (user.nickname && user.nickname.toLowerCase().indexOf(filter) !== -1);
+ });
+ }
+
+ let count;
+ if (users.length === this.props.users.length) {
+ count = (
+ <FormattedMessage
+ id='filtered_user_list.count'
+ defaultMessage='{count} {count, plural,
+ one {member}
+ other {members}
+ }'
+ values={{
+ count: users.length
+ }}
+ />
+ );
+ } else {
+ count = (
+ <FormattedMessage
+ id='filtered_user_list.countTotal'
+ defaultMessage='{count} {count, plural,
+ one {member}
+ other {members}
+ } of {total} Total'
+ values={{
+ count: users.length,
+ total: this.props.users.length
+ }}
+ />
+ );
+ }
+
+ return (
+ <div
+ className='filtered-user-list'
+ style={this.props.style}
+ >
+ <div className='filter-row'>
+ <div className='col-sm-6'>
+ <input
+ ref='filter'
+ className='form-control filter-textbox'
+ placeholder={formatMessage(holders.search)}
+ onInput={this.handleFilterChange}
+ />
+ </div>
+ <div className='col-sm-6'>
+ <span className='member-count'>{count}</span>
+ </div>
+ </div>
+ <div
+ ref='userList'
+ className='user-list'
+ >
+ <UserList
+ users={users}
+ actions={this.props.actions}
+ />
+ </div>
+ </div>
+ );
+ }
+}
+
+FilteredUserList.defaultProps = {
+ users: [],
+ actions: []
+};
+
+FilteredUserList.propTypes = {
+ intl: intlShape.isRequired,
+ users: React.PropTypes.arrayOf(React.PropTypes.object),
+ actions: React.PropTypes.arrayOf(React.PropTypes.func),
+ style: React.PropTypes.object
+};
+
+export default injectIntl(FilteredUserList);
diff --git a/web/react/components/member_list.jsx b/web/react/components/member_list.jsx
deleted file mode 100644
index 62d600279..000000000
--- a/web/react/components/member_list.jsx
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-import MemberListItem from './member_list_item.jsx';
-
-import {FormattedMessage} from 'mm-intl';
-
-export default class MemberList extends React.Component {
- render() {
- var members = [];
-
- if (this.props.memberList !== null) {
- members = this.props.memberList;
- }
-
- var message = null;
- if (members.length === 0) {
- message = (
- <tr><td>
- <FormattedMessage
- id='member_list.noUsersAdd'
- defaultMessage='No users to add.'
- />
- </td></tr>
- );
- }
-
- return (
- <table className='table more-table member-list-holder'>
- <tbody>
- {members.map(function mymembers(member) {
- return (
- <MemberListItem
- key={member.id}
- member={member}
- isAdmin={this.props.isAdmin}
- handleInvite={this.props.handleInvite}
- handleRemove={this.props.handleRemove}
- handleMakeAdmin={this.props.handleMakeAdmin}
- />
- );
- }, this)}
- {message}
- </tbody>
- </table>
- );
- }
-}
-
-MemberList.propTypes = {
- memberList: React.PropTypes.array,
- isAdmin: React.PropTypes.bool,
- handleInvite: React.PropTypes.func,
- handleRemove: React.PropTypes.func,
- handleMakeAdmin: React.PropTypes.func
-};
diff --git a/web/react/components/member_list_item.jsx b/web/react/components/member_list_item.jsx
deleted file mode 100644
index 88b98738d..000000000
--- a/web/react/components/member_list_item.jsx
+++ /dev/null
@@ -1,142 +0,0 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-import UserStore from '../stores/user_store.jsx';
-import * as Utils from '../utils/utils.jsx';
-
-import {FormattedMessage} from 'mm-intl';
-
-export default class MemberListItem extends React.Component {
- constructor(props) {
- super(props);
-
- this.handleInvite = this.handleInvite.bind(this);
- this.handleRemove = this.handleRemove.bind(this);
- this.handleMakeAdmin = this.handleMakeAdmin.bind(this);
- }
- handleInvite(e) {
- e.preventDefault();
- this.props.handleInvite(this.props.member.id);
- }
- handleRemove(e) {
- e.preventDefault();
- this.props.handleRemove(this.props.member.id);
- }
- handleMakeAdmin(e) {
- e.preventDefault();
- this.props.handleMakeAdmin(this.props.member.id);
- }
- render() {
- var member = this.props.member;
- var isAdmin = this.props.isAdmin;
- var isMemberAdmin = Utils.isAdmin(member.roles);
- var timestamp = UserStore.getCurrentUser().update_at;
-
- var invite;
- if (this.props.handleInvite) {
- invite = (
- <a
- onClick={this.handleInvite}
- className='btn btn-sm btn-primary'
- >
- <i className='glyphicon glyphicon-envelope'/>
- <FormattedMessage
- id='member_item.add'
- defaultMessage=' Add'
- />
- </a>
- );
- } else if (isAdmin && !isMemberAdmin && (member.id !== UserStore.getCurrentId())) {
- var self = this;
-
- let makeAdminOption = null;
- if (this.props.handleMakeAdmin) {
- makeAdminOption = (
- <li role='presentation'>
- <a
- href=''
- role='menuitem'
- onClick={self.handleMakeAdmin}
- >
- <FormattedMessage
- id='member_item.makeAdmin'
- defaultMessage='Make Admin'
- />
- </a>
- </li>);
- }
-
- let handleRemoveOption = null;
- if (this.props.handleRemove) {
- handleRemoveOption = (
- <li role='presentation'>
- <a
- href=''
- role='menuitem'
- onClick={self.handleRemove}
- >
- <FormattedMessage
- id='member_item.removeMember'
- defaultMessage='Remove Member'
- />
- </a>
- </li>);
- }
-
- invite = (
- <div className='dropdown member-drop'>
- <a
- href='#'
- className='dropdown-toggle theme'
- type='button'
- data-toggle='dropdown'
- aria-expanded='true'
- >
- <span className='fa fa-pencil'></span>
- <span className='text-capitalize'>
- {member.roles ||
- <FormattedMessage
- id='member_item.member'
- defaultMessage='Member'
- />
- }
- </span>
- </a>
- <ul
- className='dropdown-menu member-menu'
- role='menu'
- >
- {makeAdminOption}
- {handleRemoveOption}
- </ul>
- </div>
- );
- } else {
- invite = (<div className='member-role text-capitalize'><span className='fa fa-pencil hidden'></span>{member.roles || <FormattedMessage id='member_item.member'/>}</div>);
- }
-
- return (
- <tr>
- <td className='direct-channel'>
- <img
- className='profile-img pull-left'
- src={'/api/v1/users/' + member.id + '/image?time=' + timestamp + '&' + Utils.getSessionIndex()}
- height='36'
- width='36'
- />
- <div className='more-name'>{Utils.displayUsername(member.id)}</div>
- <div className='more-description'>{member.email}</div>
- </td>
- <td className='td--action lg'>{invite}</td>
- </tr>
- );
- }
-}
-
-MemberListItem.propTypes = {
- handleInvite: React.PropTypes.func,
- handleRemove: React.PropTypes.func,
- handleMakeAdmin: React.PropTypes.func,
- member: React.PropTypes.object,
- isAdmin: React.PropTypes.bool
-};
diff --git a/web/react/components/member_list_team.jsx b/web/react/components/member_list_team.jsx
index f1c31131f..cfd5359b7 100644
--- a/web/react/components/member_list_team.jsx
+++ b/web/react/components/member_list_team.jsx
@@ -1,7 +1,8 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import MemberListTeamItem from './member_list_team_item.jsx';
+import FilteredUserList from './filtered_user_list.jsx';
+import TeamMembersDropdown from './team_members_dropdown.jsx';
import UserStore from '../stores/user_store.jsx';
export default class MemberListTeam extends React.Component {
@@ -44,21 +45,16 @@ export default class MemberListTeam extends React.Component {
}
render() {
- const memberList = this.state.users.map((user) => {
- return (
- <MemberListTeamItem
- key={user.id}
- user={user}
- />
- );
- });
-
return (
- <table className='table more-table member-list-holder'>
- <tbody>
- {memberList}
- </tbody>
- </table>
+ <FilteredUserList
+ style={this.props.style}
+ users={this.state.users}
+ actions={[TeamMembersDropdown]}
+ />
);
}
}
+
+MemberListTeam.propTypes = {
+ style: React.PropTypes.object
+};
diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx
index 3b72b251c..0814ac1b3 100644
--- a/web/react/components/more_direct_channels.jsx
+++ b/web/react/components/more_direct_channels.jsx
@@ -2,36 +2,24 @@
// See License.txt for license information.
const Modal = ReactBootstrap.Modal;
+import FilteredUserList from './filtered_user_list.jsx';
import UserStore from '../stores/user_store.jsx';
import * as Utils from '../utils/utils.jsx';
-import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'mm-intl';
+import {FormattedMessage} from 'mm-intl';
-const holders = defineMessages({
- member: {
- id: 'more_direct_channels.member',
- defaultMessage: 'Member'
- },
- search: {
- id: 'more_direct_channels.search',
- defaultMessage: 'Search members'
- }
-});
-
-class MoreDirectChannels extends React.Component {
+export default class MoreDirectChannels extends React.Component {
constructor(props) {
super(props);
- this.handleFilterChange = this.handleFilterChange.bind(this);
this.handleHide = this.handleHide.bind(this);
this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this);
this.handleUserChange = this.handleUserChange.bind(this);
- this.createRowForUser = this.createRowForUser.bind(this);
+ this.createJoinDirectChannelButton = this.createJoinDirectChannelButton.bind(this);
this.state = {
users: this.getUsersFromStore(),
- filter: '',
loadingDMChannel: -1
};
}
@@ -60,39 +48,10 @@ class MoreDirectChannels extends React.Component {
UserStore.removeChangeListener(this.handleUserChange);
}
- componentDidUpdate(prevProps) {
- if (!prevProps.show && this.props.show) {
- this.onShow();
- }
- }
-
- onShow() {
- if (Utils.isMobile()) {
- $(ReactDOM.findDOMNode(this.refs.userList)).css('max-height', $(window).height() - 250);
- } else {
- $(ReactDOM.findDOMNode(this.refs.userList)).perfectScrollbar();
- $(ReactDOM.findDOMNode(this.refs.userList)).css('max-height', $(window).height() - 300);
- }
- }
-
- handleFilterChange() {
- const filter = ReactDOM.findDOMNode(this.refs.filter).value;
-
- if ($(window).width() > 768) {
- $(ReactDOM.findDOMNode(this.refs.userList)).scrollTop(0);
- }
-
- if (filter !== this.state.filter) {
- this.setState({filter});
- }
- }
-
handleHide() {
if (this.props.onModalDismissed) {
this.props.onModalDismissed();
}
-
- this.setState({filter: ''});
}
handleShowDirectChannel(teammate, e) {
@@ -120,145 +79,39 @@ class MoreDirectChannels extends React.Component {
this.setState({users: this.getUsersFromStore()});
}
- createRowForUser(user) {
- const details = [];
-
- const fullName = Utils.getFullName(user);
- if (fullName) {
- details.push(
- <span
- key={`${user.id}__full-name`}
- className='full-name'
- >
- {fullName}
- </span>
- );
- }
-
- if (user.nickname) {
- const separator = fullName ? ' - ' : '';
- details.push(
- <span
- key={`${user.nickname}__nickname`}
- >
- {separator + user.nickname}
- </span>
- );
- }
-
- let joinButton;
+ createJoinDirectChannelButton({user}) {
if (this.state.loadingDMChannel === user.id) {
- joinButton = (
+ return (
<img
className='channel-loading-gif'
src='/static/images/load.gif'
/>
);
- } else {
- joinButton = (
- <button
- type='button'
- className='btn btn-primary btn-message'
- onClick={this.handleShowDirectChannel.bind(this, user)}
- >
- <FormattedMessage
- id='more_direct_channels.message'
- defaultMessage='Message'
- />
- </button>
- );
}
return (
- <tr key={'direct-channel-row-user' + user.id}>
- <td
- key={user.id}
- className='direct-channel'
- >
- <img
- className='profile-img pull-left'
- width='38'
- height='38'
- src={`/api/v1/users/${user.id}/image?time=${user.update_at}&${Utils.getSessionIndex()}`}
- />
- <div className='more-name'>
- {user.username}
- </div>
- <div className='more-description'>
- {details}
- </div>
- </td>
- <td className='td--action lg'>
- {joinButton}
- </td>
- </tr>
+ <button
+ type='button'
+ className='btn btn-primary btn-message'
+ onClick={this.handleShowDirectChannel.bind(this, user)}
+ >
+ <FormattedMessage
+ id='more_direct_channels.message'
+ defaultMessage='Message'
+ />
+ </button>
);
}
render() {
- const {formatMessage} = this.props.intl;
- if (!this.props.show) {
- return null;
- }
-
- let users = this.state.users;
- if (this.state.filter) {
- const filter = this.state.filter.toLowerCase();
-
- users = users.filter((user) => {
- return user.username.toLowerCase().indexOf(filter) !== -1 ||
- user.first_name.toLowerCase().indexOf(filter) !== -1 ||
- user.last_name.toLowerCase().indexOf(filter) !== -1 ||
- user.nickname.toLowerCase().indexOf(filter) !== -1;
- });
- }
-
- const userEntries = users.map(this.createRowForUser);
-
- if (userEntries.length === 0) {
- userEntries.push(
- <tr key='no-users-found'><td>
- <FormattedMessage
- id='more_direct_channels.notFound'
- defaultMessage='No users found :('
- />
- </td></tr>);
- }
-
- let memberString = formatMessage(holders.member);
- if (users.length !== 1) {
- memberString += 's';
- }
-
- let count;
- if (users.length === this.state.users.length) {
- count = (
- <FormattedMessage
- id='more_direct_channels.count'
- defaultMessage='{count} {member}'
- values={{
- count: users.length,
- member: memberString
- }}
- />
- );
- } else {
- count = (
- <FormattedMessage
- id='more_direct_channels.countTotal'
- defaultMessage='{count} {member} of {total} Total'
- values={{
- count: users.length,
- member: memberString,
- total: this.state.users.length
- }}
- />
- );
+ let maxHeight = 1000;
+ if (Utils.windowHeight() <= 1200) {
+ maxHeight = Utils.windowHeight() - 300;
}
return (
<Modal
- dialogClassName='more-modal'
+ dialogClassName='more-modal more-direct-channels'
show={this.props.show}
onHide={this.handleHide}
>
@@ -270,30 +123,12 @@ class MoreDirectChannels extends React.Component {
/>
</Modal.Title>
</Modal.Header>
- <Modal.Body ref='modalBody'>
- <div className='filter-row'>
- <div className='col-sm-6'>
- <input
- ref='filter'
- className='form-control filter-textbox'
- placeholder={formatMessage(holders.search)}
- onInput={this.handleFilterChange}
- />
- </div>
- <div className='col-sm-6'>
- <span className='member-count'>{count}</span>
- </div>
- </div>
- <div
- ref='userList'
- className='user-list'
- >
- <table className='more-table table'>
- <tbody>
- {userEntries}
- </tbody>
- </table>
- </div>
+ <Modal.Body>
+ <FilteredUserList
+ style={{maxHeight}}
+ users={this.state.users}
+ actions={[this.createJoinDirectChannelButton]}
+ />
</Modal.Body>
<Modal.Footer>
<button
@@ -313,9 +148,6 @@ class MoreDirectChannels extends React.Component {
}
MoreDirectChannels.propTypes = {
- intl: intlShape.isRequired,
show: React.PropTypes.bool.isRequired,
onModalDismissed: React.PropTypes.func
};
-
-export default injectIntl(MoreDirectChannels);
diff --git a/web/react/components/member_list_team_item.jsx b/web/react/components/team_members_dropdown.jsx
index 23bc10781..8aacba8ca 100644
--- a/web/react/components/member_list_team_item.jsx
+++ b/web/react/components/team_members_dropdown.jsx
@@ -9,28 +9,9 @@ import * as Utils from '../utils/utils.jsx';
import ConfirmModal from './confirm_modal.jsx';
import TeamStore from '../stores/team_store.jsx';
-import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'mm-intl';
+import {FormattedMessage} from 'mm-intl';
-var holders = defineMessages({
- confirmDemoteRoleTitle: {
- id: 'member_team_item.confirmDemoteRoleTitle',
- defaultMessage: 'Confirm demotion from System Admin role'
- },
- confirmDemotion: {
- id: 'member_team_item.confirmDemotion',
- defaultMessage: 'Confirm Demotion'
- },
- confirmDemoteDescription: {
- id: 'member_team_item.confirmDemoteDescription',
- defaultMessage: 'If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you\'ll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command.'
- },
- confirmDemotionCmd: {
- id: 'member_team_item.confirmDemotionCmd',
- defaultMessage: 'platform -assign_role -team_name="yourteam" -email="name@yourcompany.com" -role="system_admin"'
- }
-});
-
-export default class MemberListTeamItem extends React.Component {
+export default class TeamMembersDropdown extends React.Component {
constructor(props) {
super(props);
@@ -159,24 +140,23 @@ export default class MemberListTeamItem extends React.Component {
const user = this.props.user;
let currentRoles = (
<FormattedMessage
- id='member_team_item.member'
+ id='team_members_dropdown.member'
defaultMessage='Member'
/>
);
- const timestamp = UserStore.getCurrentUser().update_at;
if (user.roles.length > 0) {
if (Utils.isSystemAdmin(user.roles)) {
currentRoles = (
<FormattedMessage
- id='member_team_item.systemAdmin'
+ id='team_members_dropdown.systemAdmin'
defaultMessage='System Admin'
/>
);
} else if (Utils.isAdmin(user.roles)) {
currentRoles = (
<FormattedMessage
- id='member_team_item.teamAdmin'
+ id='team_members_dropdown.teamAdmin'
defaultMessage='Team Admin'
/>
);
@@ -185,7 +165,6 @@ export default class MemberListTeamItem extends React.Component {
}
}
- const email = user.email;
let showMakeMember = user.roles === 'admin' || user.roles === 'system_admin';
let showMakeAdmin = user.roles === '' || user.roles === 'system_admin';
let showMakeActive = false;
@@ -194,7 +173,7 @@ export default class MemberListTeamItem extends React.Component {
if (user.delete_at > 0) {
currentRoles = (
<FormattedMessage
- id='member_team_item.inactive'
+ id='team_members_dropdown.inactive'
defaultMessage='Inactive'
/>
);
@@ -214,7 +193,7 @@ export default class MemberListTeamItem extends React.Component {
onClick={this.handleMakeAdmin}
>
<FormattedMessage
- id='member_team_item.makeAdmin'
+ id='team_members_dropdown.makeAdmin'
defaultMessage='Make Team Admin'
/>
</a>
@@ -232,7 +211,7 @@ export default class MemberListTeamItem extends React.Component {
onClick={this.handleMakeMember}
>
<FormattedMessage
- id='member_team_item.makeMember'
+ id='team_members_dropdown.makeMember'
defaultMessage='Make Member'
/>
</a>
@@ -250,7 +229,7 @@ export default class MemberListTeamItem extends React.Component {
onClick={this.handleMakeActive}
>
<FormattedMessage
- id='member_team_item.makeActive'
+ id='team_members_dropdown.makeActive'
defaultMessage='Make Active'
/>
</a>
@@ -268,7 +247,7 @@ export default class MemberListTeamItem extends React.Component {
onClick={this.handleMakeNotActive}
>
<FormattedMessage
- id='member_team_item.makeInactive'
+ id='team_members_dropdown.makeInactive'
defaultMessage='Make Inactive'
/>
</a>
@@ -276,15 +255,44 @@ export default class MemberListTeamItem extends React.Component {
);
}
const me = UserStore.getCurrentUser();
- const {formatMessage} = this.props.intl;
let makeDemoteModal = null;
if (this.props.user.id === me.id) {
+ const title = (
+ <FormattedMessage
+ id='team_members_dropdown.confirmDemoteRoleTitle'
+ defaultMessage='Confirm demotion from System Admin role'
+ />
+ );
+
+ const message = (
+ <div>
+ <FormattedMessage
+ id='team_members_dropdown.confirmDemoteDescription'
+ defaultMessage="If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you'll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command."
+ />
+ <br/>
+ <br/>
+ <FormattedMessage
+ id='team_members_dropdown.confirmDemotionCmd'
+ defaultMessage='platform -assign_role -team_name="yourteam" -email="name@yourcompany.com" -role="system_admin"'
+ />
+ {serverError}
+ </div>
+ );
+
+ const confirmButton = (
+ <FormattedMessage
+ id='team_members_dropdown.confirmDemotion'
+ defaultMessage='Confirm Demotion'
+ />
+ );
+
makeDemoteModal = (
<ConfirmModal
show={this.state.showDemoteModal}
- title={formatMessage(holders.confirmDemoteRoleTitle)}
- message={[formatMessage(holders.confirmDemoteDescription), React.createElement('br'), React.createElement('br'), formatMessage(holders.confirmDemotionCmd), serverError]}
- confirm_button={formatMessage(holders.confirmDemotion)}
+ title={title}
+ message={message}
+ confirmButton={confirmButton}
onConfirm={this.handleDemoteSubmit}
onCancel={this.handleDemoteCancel}
/>
@@ -292,48 +300,33 @@ export default class MemberListTeamItem extends React.Component {
}
return (
- <tr>
- <td className='row member-div'>
- <img
- className='post-profile-img pull-left'
- src={`/api/v1/users/${user.id}/image?time=${timestamp}&${Utils.getSessionIndex()}`}
- height='36'
- width='36'
- />
- <span className='more-name'>{Utils.displayUsername(user.id)}</span>
- <span className='more-description'>{email}</span>
- <div className='dropdown member-drop'>
- <a
- href='#'
- className='dropdown-toggle theme'
- type='button'
- data-toggle='dropdown'
- aria-expanded='true'
- >
- <span className='fa fa-pencil'></span>
- <span>{currentRoles} </span>
- </a>
- <ul
- className='dropdown-menu member-menu'
- role='menu'
- >
- {makeAdmin}
- {makeMember}
- {makeActive}
- {makeNotActive}
- </ul>
- </div>
- {makeDemoteModal}
- {serverError}
- </td>
- </tr>
+ <div className='dropdown member-drop'>
+ <a
+ href='#'
+ className='dropdown-toggle theme'
+ type='button'
+ data-toggle='dropdown'
+ aria-expanded='true'
+ >
+ <span className='fa fa-pencil'></span>
+ <span>{currentRoles} </span>
+ </a>
+ <ul
+ className='dropdown-menu member-menu'
+ role='menu'
+ >
+ {makeAdmin}
+ {makeMember}
+ {makeActive}
+ {makeNotActive}
+ </ul>
+ {makeDemoteModal}
+ {serverError}
+ </div>
);
}
}
-MemberListTeamItem.propTypes = {
- intl: intlShape.isRequired,
+TeamMembersDropdown.propTypes = {
user: React.PropTypes.object.isRequired
};
-
-export default injectIntl(MemberListTeamItem);
diff --git a/web/react/components/team_members_modal.jsx b/web/react/components/team_members_modal.jsx
index 8ac435742..9bdb16438 100644
--- a/web/react/components/team_members_modal.jsx
+++ b/web/react/components/team_members_modal.jsx
@@ -3,45 +3,24 @@
import MemberListTeam from './member_list_team.jsx';
import TeamStore from '../stores/team_store.jsx';
+import * as Utils from '../utils/utils.jsx';
import {FormattedMessage} from 'mm-intl';
const Modal = ReactBootstrap.Modal;
export default class TeamMembersModal extends React.Component {
- constructor(props) {
- super(props);
-
- this.onShow = this.onShow.bind(this);
- }
-
- componentDidMount() {
- if (this.props.show) {
- this.onShow();
- }
- }
-
- componentDidUpdate(prevProps) {
- if (this.props.show && !prevProps.show) {
- this.onShow();
- }
- }
-
- onShow() {
- if ($(window).width() > 768) {
- $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar();
- $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200);
- } else {
- $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 150);
- }
- }
-
render() {
const team = TeamStore.getCurrent();
+ let maxHeight = 1000;
+ if (Utils.windowHeight() <= 1200) {
+ maxHeight = Utils.windowHeight() - 300;
+ }
+
return (
<Modal
- dialogClassName='team-members-modal'
+ dialogClassName='more-modal'
show={this.props.show}
onHide={this.props.onHide}
>
@@ -54,10 +33,8 @@ export default class TeamMembersModal extends React.Component {
}}
/>
</Modal.Header>
- <Modal.Body ref='modalBody'>
- <div className='team-member-list'>
- <MemberListTeam/>
- </div>
+ <Modal.Body>
+ <MemberListTeam style={{maxHeight}}/>
</Modal.Body>
<Modal.Footer>
<button
diff --git a/web/react/components/user_list.jsx b/web/react/components/user_list.jsx
new file mode 100644
index 000000000..39453a827
--- /dev/null
+++ b/web/react/components/user_list.jsx
@@ -0,0 +1,53 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {FormattedMessage} from 'mm-intl';
+import UserListRow from './user_list_row.jsx';
+
+export default class UserList extends React.Component {
+ render() {
+ const users = this.props.users;
+
+ let content;
+ if (users.length > 0) {
+ content = users.map((user) => {
+ return (
+ <UserListRow
+ key={user.id}
+ user={user}
+ actions={this.props.actions}
+ />
+ );
+ });
+ } else {
+ content = (
+ <tr key='no-users-found'>
+ <td>
+ <FormattedMessage
+ id='user_list.notFound'
+ defaultMessage='No users found :('
+ />
+ </td>
+ </tr>
+ );
+ }
+
+ return (
+ <table className='more-table table'>
+ <tbody>
+ {content}
+ </tbody>
+ </table>
+ );
+ }
+}
+
+UserList.defaultProps = {
+ users: [],
+ actions: []
+};
+
+UserList.propTypes = {
+ users: React.PropTypes.arrayOf(React.PropTypes.object),
+ actions: React.PropTypes.arrayOf(React.PropTypes.func)
+};
diff --git a/web/react/components/user_list_row.jsx b/web/react/components/user_list_row.jsx
new file mode 100644
index 000000000..2aeca7d47
--- /dev/null
+++ b/web/react/components/user_list_row.jsx
@@ -0,0 +1,65 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import Constants from '../utils/constants.jsx';
+import PreferenceStore from '../stores/preference_store.jsx';
+import * as Utils from '../utils/utils.jsx';
+
+export default function UserListRow({user, actions}) {
+ const nameFormat = PreferenceStore.get(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', '');
+
+ let name = user.username;
+ if (user.nickname && nameFormat === Constants.Preferences.DISPLAY_PREFER_NICKNAME) {
+ name = `${user.nickname} (${user.username})`;
+ } else if ((user.first_name || user.last_name) && (nameFormat === Constants.Preferences.DISPLAY_PREFER_NICKNAME || nameFormat === Constants.Preferences.DISPLAY_PREFER_FULL_NAME)) {
+ name = `${Utils.getFullName(user)} (${user.username})`;
+ }
+
+ const buttons = actions.map((Action, index) => {
+ return (
+ <Action
+ key={index.toString()}
+ user={user}
+ />
+ );
+ });
+
+ return (
+ <tr>
+ <td
+ key={user.id}
+ className='direct-channel'
+ style={{display: 'flex'}}
+ >
+ <img
+ className='profile-img'
+ src={`/api/v1/users/${user.id}/image?time=${user.update_at}&${Utils.getSessionIndex()}`}
+ />
+ <div
+ className='user-list-item__details'
+ >
+ <div className='more-name'>
+ {name}
+ </div>
+ <div className='more-description'>
+ {user.email}
+ </div>
+ </div>
+ <div
+ className='user-list-item__actions'
+ >
+ {buttons}
+ </div>
+ </td>
+ </tr>
+ );
+}
+
+UserListRow.defaultProps = {
+ actions: []
+};
+
+UserListRow.propTypes = {
+ user: React.PropTypes.object.isRequired,
+ actions: React.PropTypes.arrayOf(React.PropTypes.func)
+};
diff --git a/web/react/components/user_settings/manage_languages.jsx b/web/react/components/user_settings/manage_languages.jsx
index fee6d9da2..2d1c74717 100644
--- a/web/react/components/user_settings/manage_languages.jsx
+++ b/web/react/components/user_settings/manage_languages.jsx
@@ -1,6 +1,8 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
+import SettingItemMax from '../setting_item_max.jsx';
+
import * as Client from '../../utils/client.jsx';
import * as Utils from '../../utils/utils.jsx';
@@ -69,7 +71,7 @@ export default class ManageLanguage extends React.Component {
</option>);
});
- return (
+ const input = (
<div key='changeLanguage'>
<br/>
<label className='control-label'>
@@ -88,24 +90,28 @@ export default class ManageLanguage extends React.Component {
{options}
</select>
{serverError}
- <div className='padding-top'>
- <a
- className={'btn btn-sm btn-primary'}
- href='#'
- onClick={this.changeLanguage}
- >
- <FormattedMessage
- id='user.settings.languages'
- defaultMessage='Set language'
- />
- </a>
- </div>
</div>
</div>
);
+
+ return (
+ <SettingItemMax
+ title={
+ <FormattedMessage
+ id='user.settings.display.language'
+ defaultMessage='Language'
+ />
+ }
+ width='medium'
+ submit={this.changeLanguage}
+ inputs={[input]}
+ updateSection={this.props.updateSection}
+ />
+ );
}
}
ManageLanguage.propTypes = {
- user: React.PropTypes.object
-}; \ No newline at end of file
+ user: React.PropTypes.object.isRequired,
+ updateSection: React.PropTypes.func.isRequired
+};
diff --git a/web/react/components/user_settings/user_settings_display.jsx b/web/react/components/user_settings/user_settings_display.jsx
index 5f23a8995..3e468e08f 100644
--- a/web/react/components/user_settings/user_settings_display.jsx
+++ b/web/react/components/user_settings/user_settings_display.jsx
@@ -12,46 +12,7 @@ import * as Utils from '../../utils/utils.jsx';
import Constants from '../../utils/constants.jsx';
import {savePreferences} from '../../utils/client.jsx';
-import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'mm-intl';
-
-const holders = defineMessages({
- normalClock: {
- id: 'user.settings.display.normalClock',
- defaultMessage: '12-hour clock (example: 4:00 PM)'
- },
- militaryClock: {
- id: 'user.settings.display.militaryClock',
- defaultMessage: '24-hour clock (example: 16:00)'
- },
- clockDisplay: {
- id: 'user.settings.display.clockDisplay',
- defaultMessage: 'Clock Display'
- },
- teammateDisplay: {
- id: 'user.settings.display.teammateDisplay',
- defaultMessage: 'Teammate Name Display'
- },
- showNickname: {
- id: 'user.settings.display.showNickname',
- defaultMessage: 'Show nickname if one exists, otherwise show first and last name'
- },
- showUsername: {
- id: 'user.settings.display.showUsername',
- defaultMessage: 'Show username (team default)'
- },
- showFullname: {
- id: 'user.settings.display.showFullname',
- defaultMessage: 'Show first and last name'
- },
- fontTitle: {
- id: 'user.settings.display.fontTitle',
- defaultMessage: 'Display Font'
- },
- language: {
- id: 'user.settings.display.language',
- defaultMessage: 'Language'
- }
-});
+import {FormattedMessage} from 'mm-intl';
function getDisplayStateFromStores() {
const militaryTime = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', {value: 'false'});
@@ -65,7 +26,7 @@ function getDisplayStateFromStores() {
};
}
-class UserSettingsDisplay extends React.Component {
+export default class UserSettingsDisplay extends React.Component {
constructor(props) {
super(props);
@@ -119,7 +80,6 @@ class UserSettingsDisplay extends React.Component {
this.updateState();
}
render() {
- const {formatMessage} = this.props.intl;
const serverError = this.state.serverError || null;
let clockSection;
let nameFormatSection;
@@ -181,7 +141,12 @@ class UserSettingsDisplay extends React.Component {
clockSection = (
<SettingItemMax
- title={formatMessage(holders.clockDisplay)}
+ title={
+ <FormattedMessage
+ id='user.settings.display.clockDisplay'
+ defaultMessage='Clock Display'
+ />
+ }
inputs={inputs}
submit={this.handleSubmit}
server_error={serverError}
@@ -189,11 +154,21 @@ class UserSettingsDisplay extends React.Component {
/>
);
} else {
- let describe = '';
+ let describe;
if (this.state.militaryTime === 'true') {
- describe = formatMessage(holders.militaryClock);
+ describe = (
+ <FormattedMessage
+ id='user.settings.display.militaryClock'
+ defaultMessage='24-hour clock (example: 16:00)'
+ />
+ );
} else {
- describe = formatMessage(holders.normalClock);
+ describe = (
+ <FormattedMessage
+ id='user.settings.display.normalClock'
+ defaultMessage='12-hour clock (example: 4:00 PM)'
+ />
+ );
}
const handleUpdateClockSection = () => {
@@ -202,7 +177,12 @@ class UserSettingsDisplay extends React.Component {
clockSection = (
<SettingItemMin
- title={formatMessage(holders.clockDisplay)}
+ title={
+ <FormattedMessage
+ id='user.settings.display.clockDisplay'
+ defaultMessage='Clock Display'
+ />
+ }
describe={describe}
updateSection={handleUpdateClockSection}
/>
@@ -284,7 +264,12 @@ class UserSettingsDisplay extends React.Component {
nameFormatSection = (
<SettingItemMax
- title={formatMessage(holders.teammateDisplay)}
+ title={
+ <FormattedMessage
+ id='user.settings.display.teammateDisplay'
+ defaultMessage='Teammate Name Display'
+ />
+ }
inputs={inputs}
submit={this.handleSubmit}
server_error={serverError}
@@ -295,18 +280,38 @@ class UserSettingsDisplay extends React.Component {
/>
);
} else {
- let describe = '';
+ let describe;
if (this.state.nameFormat === 'username') {
- describe = formatMessage(holders.showUsername);
+ describe = (
+ <FormattedMessage
+ id='user.settings.display.showUsername'
+ defaultMessage='Show username (team default)'
+ />
+ );
} else if (this.state.nameFormat === 'full_name') {
- describe = formatMessage(holders.showFullname);
+ describe = (
+ <FormattedMessage
+ id='user.settings.display.showFullname'
+ defaultMessage='Show first and last name'
+ />
+ );
} else {
- describe = formatMessage(holders.showNickname);
+ describe = (
+ <FormattedMessage
+ id='user.settings.display.showNickname'
+ defaultMessage='Show nickname if one exists, otherwise show first and last name'
+ />
+ );
}
nameFormatSection = (
<SettingItemMin
- title={formatMessage(holders.teammateDisplay)}
+ title={
+ <FormattedMessage
+ id='user.settings.display.teammateDisplay'
+ defaultMessage='Teammate Name Display'
+ />
+ }
describe={describe}
updateSection={() => {
this.props.updateSection('name_format');
@@ -356,7 +361,12 @@ class UserSettingsDisplay extends React.Component {
fontSection = (
<SettingItemMax
- title={formatMessage(holders.fontTitle)}
+ title={
+ <FormattedMessage
+ id='user.settings.display.fontTitle'
+ defaultMessage='Display Font'
+ />
+ }
inputs={inputs}
submit={this.handleSubmit}
server_error={serverError}
@@ -369,7 +379,12 @@ class UserSettingsDisplay extends React.Component {
} else {
fontSection = (
<SettingItemMin
- title={formatMessage(holders.fontTitle)}
+ title={
+ <FormattedMessage
+ id='user.settings.display.fontTitle'
+ defaultMessage='Display Font'
+ />
+ }
describe={this.state.selectedFont}
updateSection={() => {
this.props.updateSection('font');
@@ -379,19 +394,9 @@ class UserSettingsDisplay extends React.Component {
}
if (this.props.activeSection === 'languages') {
- var inputs = [];
- inputs.push(
+ languagesSection = (
<ManageLanguages
user={this.props.user}
- key='languages-ui'
- />
- );
-
- languagesSection = (
- <SettingItemMax
- title={formatMessage(holders.language)}
- width='medium'
- inputs={inputs}
updateSection={(e) => {
this.updateSection('');
e.preventDefault();
@@ -408,7 +413,12 @@ class UserSettingsDisplay extends React.Component {
languagesSection = (
<SettingItemMin
- title={formatMessage(holders.language)}
+ title={
+ <FormattedMessage
+ id='user.settings.display.language'
+ defaultMessage='Language'
+ />
+ }
width='medium'
describe={locale}
updateSection={() => {
@@ -452,12 +462,12 @@ class UserSettingsDisplay extends React.Component {
/>
</h3>
<div className='divider-dark first'/>
- <ThemeSetting
- selected={this.props.activeSection === 'theme'}
- updateSection={this.updateSection}
- setRequireConfirm={this.props.setRequireConfirm}
- setEnforceFocus={this.props.setEnforceFocus}
- />
+ <ThemeSetting
+ selected={this.props.activeSection === 'theme'}
+ updateSection={this.updateSection}
+ setRequireConfirm={this.props.setRequireConfirm}
+ setEnforceFocus={this.props.setEnforceFocus}
+ />
<div className='divider-dark'/>
{fontSection}
<div className='divider-dark'/>
@@ -473,7 +483,6 @@ class UserSettingsDisplay extends React.Component {
}
UserSettingsDisplay.propTypes = {
- intl: intlShape.isRequired,
user: React.PropTypes.object,
updateSection: React.PropTypes.func,
updateTab: React.PropTypes.func,
@@ -483,5 +492,3 @@ UserSettingsDisplay.propTypes = {
setRequireConfirm: React.PropTypes.func.isRequired,
setEnforceFocus: React.PropTypes.func.isRequired
};
-
-export default injectIntl(UserSettingsDisplay);
diff --git a/web/react/stores/analytics_store.jsx b/web/react/stores/analytics_store.jsx
new file mode 100644
index 000000000..ec827f6d7
--- /dev/null
+++ b/web/react/stores/analytics_store.jsx
@@ -0,0 +1,85 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
+import EventEmitter from 'events';
+
+import Constants from '../utils/constants.jsx';
+const ActionTypes = Constants.ActionTypes;
+
+const CHANGE_EVENT = 'change';
+
+class AnalyticsStoreClass extends EventEmitter {
+ constructor() {
+ super();
+ this.systemStats = {};
+ this.teamStats = {};
+ }
+
+ emitChange() {
+ this.emit(CHANGE_EVENT);
+ }
+
+ addChangeListener(callback) {
+ this.on(CHANGE_EVENT, callback);
+ }
+
+ removeChangeListener(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ }
+
+ getAllSystem() {
+ return JSON.parse(JSON.stringify(this.systemStats));
+ }
+
+ getAllTeam(id) {
+ if (id in this.teamStats) {
+ return JSON.parse(JSON.stringify(this.teamStats[id]));
+ }
+
+ return {};
+ }
+
+ storeSystemStats(newStats) {
+ for (const stat in newStats) {
+ if (!newStats.hasOwnProperty(stat)) {
+ continue;
+ }
+ this.systemStats[stat] = newStats[stat];
+ }
+ }
+
+ storeTeamStats(id, newStats) {
+ if (!(id in this.teamStats)) {
+ this.teamStats[id] = {};
+ }
+
+ for (const stat in newStats) {
+ if (!newStats.hasOwnProperty(stat)) {
+ continue;
+ }
+ this.teamStats[id][stat] = newStats[stat];
+ }
+ }
+
+}
+
+var AnalyticsStore = new AnalyticsStoreClass();
+
+AnalyticsStore.dispatchToken = AppDispatcher.register((payload) => {
+ var action = payload.action;
+
+ switch (action.type) {
+ case ActionTypes.RECEIVED_ANALYTICS:
+ if (action.teamId == null) {
+ AnalyticsStore.storeSystemStats(action.stats);
+ } else {
+ AnalyticsStore.storeTeamStats(action.teamId, action.stats);
+ }
+ AnalyticsStore.emitChange();
+ break;
+ default:
+ }
+});
+
+export default AnalyticsStore;
diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx
index ca9d81865..7d5e1bd0f 100644
--- a/web/react/utils/async_client.jsx
+++ b/web/react/utils/async_client.jsx
@@ -11,16 +11,17 @@ import UserStore from '../stores/user_store.jsx';
import * as utils from './utils.jsx';
import Constants from './constants.jsx';
-var ActionTypes = Constants.ActionTypes;
+const ActionTypes = Constants.ActionTypes;
+const StatTypes = Constants.StatTypes;
// Used to track in progress async calls
-var callTracker = {};
+const callTracker = {};
export function dispatchError(err, method) {
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_ERROR,
- err: err,
- method: method
+ err,
+ method
});
}
@@ -848,3 +849,264 @@ export function getFileInfo(filename) {
}
);
}
+
+export function getStandardAnalytics(teamId) {
+ const callName = 'getStandardAnaytics' + teamId;
+
+ if (isCallInProgress(callName)) {
+ return;
+ }
+
+ callTracker[callName] = utils.getTimestamp();
+
+ client.getAnalytics(
+ 'standard',
+ teamId,
+ (data) => {
+ callTracker[callName] = 0;
+
+ const stats = {};
+
+ for (const index in data) {
+ if (data[index].name === 'channel_open_count') {
+ stats[StatTypes.TOTAL_PUBLIC_CHANNELS] = data[index].value;
+ }
+
+ if (data[index].name === 'channel_private_count') {
+ stats[StatTypes.TOTAL_PRIVATE_GROUPS] = data[index].value;
+ }
+
+ if (data[index].name === 'post_count') {
+ stats[StatTypes.TOTAL_POSTS] = data[index].value;
+ }
+
+ if (data[index].name === 'unique_user_count') {
+ stats[StatTypes.TOTAL_USERS] = data[index].value;
+ }
+
+ if (data[index].name === 'team_count' && teamId == null) {
+ stats[StatTypes.TOTAL_TEAMS] = data[index].value;
+ }
+ }
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_ANALYTICS,
+ teamId,
+ stats
+ });
+ },
+ (err) => {
+ callTracker[callName] = 0;
+
+ dispatchError(err, 'getStandardAnalytics');
+ }
+ );
+}
+
+export function getAdvancedAnalytics(teamId) {
+ const callName = 'getAdvancedAnalytics' + teamId;
+
+ if (isCallInProgress(callName)) {
+ return;
+ }
+
+ callTracker[callName] = utils.getTimestamp();
+
+ client.getAnalytics(
+ 'extra_counts',
+ teamId,
+ (data) => {
+ callTracker[callName] = 0;
+
+ const stats = {};
+
+ for (const index in data) {
+ if (data[index].name === 'file_post_count') {
+ stats[StatTypes.TOTAL_FILE_POSTS] = data[index].value;
+ }
+
+ if (data[index].name === 'hashtag_post_count') {
+ stats[StatTypes.TOTAL_HASHTAG_POSTS] = data[index].value;
+ }
+
+ if (data[index].name === 'incoming_webhook_count') {
+ stats[StatTypes.TOTAL_IHOOKS] = data[index].value;
+ }
+
+ if (data[index].name === 'outgoing_webhook_count') {
+ stats[StatTypes.TOTAL_OHOOKS] = data[index].value;
+ }
+
+ if (data[index].name === 'command_count') {
+ stats[StatTypes.TOTAL_COMMANDS] = data[index].value;
+ }
+
+ if (data[index].name === 'session_count') {
+ stats[StatTypes.TOTAL_SESSIONS] = data[index].value;
+ }
+ }
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_ANALYTICS,
+ teamId,
+ stats
+ });
+ },
+ (err) => {
+ callTracker[callName] = 0;
+
+ dispatchError(err, 'getAdvancedAnalytics');
+ }
+ );
+}
+
+export function getPostsPerDayAnalytics(teamId) {
+ const callName = 'getPostsPerDayAnalytics' + teamId;
+
+ if (isCallInProgress(callName)) {
+ return;
+ }
+
+ callTracker[callName] = utils.getTimestamp();
+
+ client.getAnalytics(
+ 'post_counts_day',
+ teamId,
+ (data) => {
+ callTracker[callName] = 0;
+
+ data.reverse();
+
+ const stats = {};
+ stats[StatTypes.POST_PER_DAY] = data;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_ANALYTICS,
+ teamId,
+ stats
+ });
+ },
+ (err) => {
+ callTracker[callName] = 0;
+
+ dispatchError(err, 'getPostsPerDayAnalytics');
+ }
+ );
+}
+
+export function getUsersPerDayAnalytics(teamId) {
+ const callName = 'getUsersPerDayAnalytics' + teamId;
+
+ if (isCallInProgress(callName)) {
+ return;
+ }
+
+ callTracker[callName] = utils.getTimestamp();
+
+ client.getAnalytics(
+ 'user_counts_with_posts_day',
+ teamId,
+ (data) => {
+ callTracker[callName] = 0;
+
+ data.reverse();
+
+ const stats = {};
+ stats[StatTypes.USERS_WITH_POSTS_PER_DAY] = data;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_ANALYTICS,
+ teamId,
+ stats
+ });
+ },
+ (err) => {
+ callTracker[callName] = 0;
+
+ dispatchError(err, 'getUsersPerDayAnalytics');
+ }
+ );
+}
+
+export function getRecentAndNewUsersAnalytics(teamId) {
+ const callName = 'getRecentAndNewUsersAnalytics' + teamId;
+
+ if (isCallInProgress(callName)) {
+ return;
+ }
+
+ callTracker[callName] = utils.getTimestamp();
+
+ client.getProfilesForTeam(
+ teamId,
+ (users) => {
+ const stats = {};
+
+ const usersList = [];
+ for (const id in users) {
+ if (users.hasOwnProperty(id)) {
+ usersList.push(users[id]);
+ }
+ }
+
+ usersList.sort((a, b) => {
+ if (a.last_activity_at < b.last_activity_at) {
+ return 1;
+ }
+
+ if (a.last_activity_at > b.last_activity_at) {
+ return -1;
+ }
+
+ return 0;
+ });
+
+ const recentActive = [];
+ for (let i = 0; i < usersList.length; i++) {
+ if (usersList[i].last_activity_at == null) {
+ continue;
+ }
+
+ recentActive.push(usersList[i]);
+ if (i >= Constants.STAT_MAX_ACTIVE_USERS) {
+ break;
+ }
+ }
+
+ stats[StatTypes.RECENTLY_ACTIVE_USERS] = recentActive;
+
+ usersList.sort((a, b) => {
+ if (a.create_at < b.create_at) {
+ return 1;
+ }
+
+ if (a.create_at > b.create_at) {
+ return -1;
+ }
+
+ return 0;
+ });
+
+ var newlyCreated = [];
+ for (let i = 0; i < usersList.length; i++) {
+ newlyCreated.push(usersList[i]);
+ if (i >= Constants.STAT_MAX_NEW_USERS) {
+ break;
+ }
+ }
+
+ stats[StatTypes.NEWLY_CREATED_USERS] = newlyCreated;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_ANALYTICS,
+ teamId,
+ stats
+ });
+ },
+ (err) => {
+ callTracker[callName] = 0;
+
+ dispatchError(err, 'getRecentAndNewUsersAnalytics');
+ }
+ );
+}
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
index f647e2296..1a002bc8c 100644
--- a/web/react/utils/client.jsx
+++ b/web/react/utils/client.jsx
@@ -435,23 +435,16 @@ export function getConfig(success, error) {
});
}
-export function getTeamAnalytics(teamId, name, success, error) {
- $.ajax({
- url: '/api/v1/admin/analytics/' + teamId + '/' + name,
- dataType: 'json',
- contentType: 'application/json',
- type: 'GET',
- success,
- error: (xhr, status, err) => {
- var e = handleError('getTeamAnalytics', xhr, status, err);
- error(e);
- }
- });
-}
+export function getAnalytics(name, teamId, success, error) {
+ let url = '/api/v1/admin/analytics/';
+ if (teamId == null) {
+ url += name;
+ } else {
+ url += teamId + '/' + name;
+ }
-export function getSystemAnalytics(name, success, error) {
$.ajax({
- url: '/api/v1/admin/analytics/' + name,
+ url,
dataType: 'json',
contentType: 'application/json',
type: 'GET',
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
index 0a4944708..daea9f43e 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -71,6 +71,26 @@ export default {
VIEW_ACTION: null
}),
+ StatTypes: keyMirror({
+ TOTAL_USERS: null,
+ TOTAL_PUBLIC_CHANNELS: null,
+ TOTAL_PRIVATE_GROUPS: null,
+ TOTAL_POSTS: null,
+ TOTAL_TEAMS: null,
+ TOTAL_FILE_POSTS: null,
+ TOTAL_HASHTAG_POSTS: null,
+ TOTAL_IHOOKS: null,
+ TOTAL_OHOOKS: null,
+ TOTAL_COMMANDS: null,
+ TOTAL_SESSIONS: null,
+ POST_PER_DAY: null,
+ USERS_WITH_POSTS_PER_DAY: null,
+ RECENTLY_ACTIVE_USERS: null,
+ NEWLY_CREATED_USERS: null
+ }),
+ STAT_MAX_ACTIVE_USERS: 20,
+ STAT_MAX_NEW_USERS: 20,
+
SocketEvents: {
POSTED: 'posted',
POST_EDITED: 'post_edited',
@@ -417,6 +437,8 @@ export default {
Preferences: {
CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show',
CATEGORY_DISPLAY_SETTINGS: 'display_settings',
+ DISPLAY_PREFER_NICKNAME: 'nickname_full_name',
+ DISPLAY_PREFER_FULL_NAME: 'full_name',
CATEGORY_ADVANCED_SETTINGS: 'advanced_settings',
TUTORIAL_STEP: 'tutorial_step'
},
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index 3e531c821..6ab2f64d4 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -1082,9 +1082,9 @@ export function displayUsername(userId) {
let username = '';
if (user) {
- if (nameFormat === 'nickname_full_name') {
+ if (nameFormat === Constants.Preferences.DISPLAY_PREFER_NICKNAME) {
username = user.nickname || getFullName(user);
- } else if (nameFormat === 'full_name') {
+ } else if (nameFormat === Constants.Preferences.DISPLAY_PREFER_FULL_NAME) {
username = getFullName(user);
}
if (!username.trim().length) {
diff --git a/web/sass-files/sass/partials/_admin-console.scss b/web/sass-files/sass/partials/_admin-console.scss
index dc8b950e4..f782da36b 100644
--- a/web/sass-files/sass/partials/_admin-console.scss
+++ b/web/sass-files/sass/partials/_admin-console.scss
@@ -235,4 +235,12 @@
}
}
}
+
+ .member-list-holder {
+ .member-role, .member-drop {
+ position: absolute;
+ right: 15px;
+ top: 8px;
+ }
+ }
}
diff --git a/web/sass-files/sass/partials/_modal.scss b/web/sass-files/sass/partials/_modal.scss
index 09c366c77..ca83c77da 100644
--- a/web/sass-files/sass/partials/_modal.scss
+++ b/web/sass-files/sass/partials/_modal.scss
@@ -9,6 +9,7 @@
}
.more-table {
margin: 0;
+ table-layout: fixed;
p {
font-size: 0.9em;
overflow: hidden;
@@ -52,12 +53,6 @@
&.padding--equal {
padding: 8px;
}
- &.td--action {
- text-align: right;
- padding: 8px 15px 8px 8px;
- vertical-align: middle;
- position: relative;
- }
}
}
}
@@ -398,7 +393,6 @@
overflow-y: auto;
overflow-x: hidden;
margin-top: 10px;
- max-height: 500px;
position: relative;
}
@@ -434,3 +428,50 @@
max-height: 150px;
}
}
+
+.user-list {
+ display: flex;
+ flex-direction: column;
+
+ .profile-img {
+ width: 38px;
+ height: 38px;
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+
+ .user-list-item__details {
+ flex-grow: 1;
+ flex-shrink: 1;
+ overflow:hidden;
+ text-overflow: ellipsis;
+
+ .more-name {
+ white-space: nowrap;
+ }
+
+ .more-description {
+ white-space: nowrap;
+ }
+ }
+
+ .user-list-item__actions {
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+}
+
+.filtered-user-list {
+ display: flex;
+ flex-direction: column;
+
+ .filter-row {
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+
+ .user-list {
+ flex-grow: 1;
+ flex-shrink: 1;
+ }
+}
diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss
index ead65af2e..0f978a806 100644
--- a/web/sass-files/sass/partials/_responsive.scss
+++ b/web/sass-files/sass/partials/_responsive.scss
@@ -572,8 +572,7 @@
.glyphicon-refresh-animate {
right: 33px;
top: 15px;
- color: #fff;
- color: rgba(255,255,255,0.5);
+ color: #aaa;
}
.form-control {
border: none;
diff --git a/web/sass-files/sass/partials/_settings.scss b/web/sass-files/sass/partials/_settings.scss
index 8508ce6fc..99b564fe5 100644
--- a/web/sass-files/sass/partials/_settings.scss
+++ b/web/sass-files/sass/partials/_settings.scss
@@ -399,10 +399,6 @@ h3 {
}
.member-role, .member-drop {
- position:absolute;
- right: 15px;
- top: 8px;
-
.fa {
margin-right: 5px;
@include opacity(0.5);
@@ -413,7 +409,6 @@ h3 {
top: -50%;
right: 110%;
}
-
}
.member-invite {
diff --git a/web/static/i18n/en.json b/web/static/i18n/en.json
index f4d520014..7d427935c 100644
--- a/web/static/i18n/en.json
+++ b/web/static/i18n/en.json
@@ -21,23 +21,6 @@
"activity_log_modal.android": "Android",
"activity_log_modal.androidNativeApp": "Android Native App",
"activity_log_modal.iphoneNativeApp": "iPhone Native App",
- "admin.analytics.activeUsers": "Active Users With Posts",
- "admin.analytics.channelTypes": "Channel Types",
- "admin.analytics.loading": "Loading...",
- "admin.analytics.meaningful": "Not enough data for a meaningful representation.",
- "admin.analytics.newlyCreated": "Newly Created Users",
- "admin.analytics.postTypes": "Posts, Files and Hashtags",
- "admin.analytics.privateGroups": "Private Groups",
- "admin.analytics.publicChannels": "Public Channels",
- "admin.analytics.recentActive": "Recent Active Users",
- "admin.analytics.textPosts": "Posts with Text-only",
- "admin.analytics.title": "Statistics for {title}",
- "admin.analytics.totalFilePosts": "Posts with Files",
- "admin.analytics.totalHashtagPosts": "Posts with Hashtags",
- "admin.analytics.totalIncomingWebhooks": "Incoming Webhooks",
- "admin.analytics.totalOutgoingWebhooks": "Outgoing Webhooks",
- "admin.analytics.totalPosts": "Total Posts",
- "admin.analytics.totalUsers": "Total Users",
"admin.audits.reload": "Reload",
"admin.audits.title": "User Activity",
"admin.email.allowEmailSignInDescription": "When true, Mattermost allows users to sign in using their email and password.",
@@ -294,6 +277,9 @@
"admin.service.attemptTitle": "Maximum Login Attempts:",
"admin.service.cmdsDesc": "When true, user created slash commands will be allowed.",
"admin.service.cmdsTitle": "Enable Slash Commands: ",
+ "admin.service.corsEx": "http://example.com https://example.com",
+ "admin.service.corsDescription": "Enable HTTP Cross origin request from specific domains (separate by a spacebar). Use \"*\" if you want to allow CORS from any domain or leave it blank to disable it.",
+ "admin.service.corsTitle": "Allow Cross-origin Requests from:",
"admin.service.developerDesc": "(Developer Option) When true, extra information around errors will be displayed in the UI.",
"admin.service.developerTitle": "Enable Developer Mode: ",
"admin.service.false": "false",
@@ -441,6 +427,33 @@
"admin.user_item.resetPwd": "Reset Password",
"admin.user_item.sysAdmin": "System Admin",
"admin.user_item.teamAdmin": "Team Admin",
+ "analytics.chart.loading": "Loading...",
+ "analytics.chart.meaningful": "Not enough data for a meaningful representation.",
+ "analytics.system.activeUsers": "Active Users With Posts",
+ "analytics.system.channelTypes": "Channel Types",
+ "analytics.system.postTypes": "Posts, Files and Hashtags",
+ "analytics.system.privateGroups": "Private Groups",
+ "analytics.system.publicChannels": "Public Channels",
+ "analytics.system.textPosts": "Posts with Text-only",
+ "analytics.system.title": "System Statistics",
+ "analytics.system.totalChannels": "Total Channels",
+ "analytics.system.totalCommands": "Total Commands",
+ "analytics.system.totalFilePosts": "Posts with Files",
+ "analytics.system.totalHashtagPosts": "Posts with Hashtags",
+ "analytics.system.totalIncomingWebhooks": "Incoming Webhooks",
+ "analytics.system.totalOutgoingWebhooks": "Outgoing Webhooks",
+ "analytics.system.totalPosts": "Total Posts",
+ "analytics.system.totalSessions": "Total Sessions",
+ "analytics.system.totalTeams": "Total Teams",
+ "analytics.system.totalUsers": "Total Users",
+ "analytics.team.activeUsers": "Active Users With Posts",
+ "analytics.team.newlyCreated": "Newly Created Users",
+ "analytics.team.privateGroups": "Private Groups",
+ "analytics.team.publicChannels": "Public Channels",
+ "analytics.team.recentActive": "Recent Active Users",
+ "analytics.team.title": "Team Statistics for {team}",
+ "analytics.team.totalPosts": "Total Posts",
+ "analytics.team.totalUsers": "Total Users",
"audit_table.accountActive": "Account made active",
"audit_table.accountInactive": "Account made inactive",
"audit_table.action": "Action",
@@ -534,19 +547,21 @@
"channel_info.notFound": "No Channel Found",
"channel_info.purpose": "Channel Purpose:",
"channel_info.url": "Channel URL:",
+ "channel_invite.add": " Add",
"channel_invite.addNewMembers": "Add New Members to ",
"channel_invite.close": "Close",
+ "channel_loader.connection_error": "There appears to be a problem with your internet connection.",
"channel_loader.posted": "Posted",
"channel_loader.socketError": "Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.",
"channel_loader.someone": "Someone",
"channel_loader.something": " did something new",
+ "channel_loader.unknown_error": "We received an unexpected status code from the server.",
"channel_loader.uploadedFile": " uploaded a file",
"channel_loader.uploadedImage": " uploaded an image",
"channel_loader.wrote": " wrote: ",
- "channel_loader.connection_error": "There appears to be a problem with your internet connection.",
- "channel_loader.unknown_error": "We received an unexpected status code from the server.",
"channel_members_modal.addNew": " Add New Members",
"channel_members_modal.close": "Close",
+ "channel_members_modal.removeMember": "Remove Member",
"channel_memebers_modal.members": " Members",
"channel_modal.cancel": "Cancel",
"channel_modal.channel": "Channel",
@@ -654,6 +669,9 @@
"file_upload.filesAbove": "Files above {max}MB could not be uploaded: {filenames}",
"file_upload.limited": "Uploads limited to {count} files maximum. Please use additional posts for more files.",
"file_upload.pasted": "Image Pasted at ",
+ "filtered_user_list.count": "{count, number} {count, plural, one {member} other {members}}",
+ "filtered_user_list.countTotal": "{count, number} {count, plural, one {member} other {members}} of {total} Total",
+ "filtered_user_list.search": "Search members",
"find_team.email": "Email",
"find_team.findDescription": "An email was sent with links to any teams to which you are a member.",
"find_team.findTitle": "Find Your Team",
@@ -751,23 +769,9 @@
"login_username.username": "Username",
"login_username.usernameReq": "A username is required",
"login_username.verifyEmailError": "Please verify your email address. Check your inbox for an email.",
- "member_item.add": " Add",
"member_item.makeAdmin": "Make Admin",
"member_item.member": "Member",
- "member_item.removeMember": "Remove Member",
"member_list.noUsersAdd": "No users to add.",
- "member_team_item.confirmDemoteDescription": "If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you'll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command.",
- "member_team_item.confirmDemoteRoleTitle": "Confirm demotion from System Admin role",
- "member_team_item.confirmDemotion": "Confirm Demotion",
- "member_team_item.confirmDemotionCmd": "platform -assign_role -team_name=\"yourteam\" -email=\"name@yourcompany.com\" -role=\"system_admin\"",
- "member_team_item.inactive": "Inactive",
- "member_team_item.makeActive": "Make Active",
- "member_team_item.makeAdmin": "Make Team Admin",
- "member_team_item.makeInactive": "Make Inactive",
- "member_team_item.makeMember": "Make Member",
- "member_team_item.member": "Member",
- "member_team_item.systemAdmin": "System Admin",
- "member_team_item.teamAdmin": "Team Admin",
"members_popover.msg": "Message",
"members_popover.title": "Members",
"more_channels.close": "Close",
@@ -777,12 +781,7 @@
"more_channels.noMore": "No more channels to join",
"more_channels.title": "More Channels",
"more_direct_channels.close": "Close",
- "more_direct_channels.count": "{count} {member}",
- "more_direct_channels.countTotal": "{count} {member} of {total} Total",
- "more_direct_channels.member": "Member",
"more_direct_channels.message": "Message",
- "more_direct_channels.notFound": "No users found :(",
- "more_direct_channels.search": "Search members",
"more_direct_channels.title": "Direct Messages",
"msg_typing.areTyping": "{users} and {last} are typing...",
"msg_typing.isTyping": "{user} is typing...",
@@ -984,6 +983,18 @@
"team_import_tab.summary": "View Summary",
"team_member_modal.close": "Close",
"team_member_modal.members": "{team} Members",
+ "team_members_dropdown.confirmDemoteDescription": "If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you'll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command.",
+ "team_members_dropdown.confirmDemoteRoleTitle": "Confirm demotion from System Admin role",
+ "team_members_dropdown.confirmDemotion": "Confirm Demotion",
+ "team_members_dropdown.confirmDemotionCmd": "platform -assign_role -team_name=\"yourteam\" -email=\"name@yourcompany.com\" -role=\"system_admin\"",
+ "team_members_dropdown.inactive": "Inactive",
+ "team_members_dropdown.makeActive": "Make Active",
+ "team_members_dropdown.makeAdmin": "Make Team Admin",
+ "team_members_dropdown.makeInactive": "Make Inactive",
+ "team_members_dropdown.makeMember": "Make Member",
+ "team_members_dropdown.member": "Member",
+ "team_members_dropdown.systemAdmin": "System Admin",
+ "team_members_dropdown.teamAdmin": "Team Admin",
"team_settings_modal.exportTab": "Export",
"team_settings_modal.generalTab": "General",
"team_settings_modal.importTab": "Import",
@@ -1218,7 +1229,6 @@
"user.settings.integrations.outWebhooks": "Outgoing Webhooks",
"user.settings.integrations.outWebhooksDescription": "Manage your outgoing webhooks",
"user.settings.integrations.title": "Integration Settings",
- "user.settings.languages": "Set language",
"user.settings.languages.change": "Change interface language",
"user.settings.modal.advanced": "Advanced",
"user.settings.modal.confirmBtns": "Yes, Discard",
@@ -1273,6 +1283,7 @@
"user.settings.security.switchGoogle": "Switch to using Google SSO",
"user.settings.security.title": "Security Settings",
"user.settings.security.viewHistory": "View Access History",
+ "user_list.notFound": "No users found :(",
"user_profile.notShared": "Email not shared",
"view_image.loading": "Loading ",
"view_image_popover.download": "Download",
diff --git a/web/static/i18n/es.json b/web/static/i18n/es.json
index 17f07205a..e3f23e77e 100644
--- a/web/static/i18n/es.json
+++ b/web/static/i18n/es.json
@@ -21,23 +21,6 @@
"activity_log_modal.android": "Android",
"activity_log_modal.androidNativeApp": "Android App Nativa",
"activity_log_modal.iphoneNativeApp": "iPhone App Nativa",
- "admin.analytics.activeUsers": "Usuarios Activos con Mensajes",
- "admin.analytics.channelTypes": "Tipos de Canales",
- "admin.analytics.loading": "Cargando...",
- "admin.analytics.meaningful": "No hay suficiente data para tener una representación significativa.",
- "admin.analytics.newlyCreated": "Nuevos Usuarios Creados",
- "admin.analytics.postTypes": "Mesajes, Archivos y Hashtags",
- "admin.analytics.privateGroups": "Grupos Privados",
- "admin.analytics.publicChannels": "Canales Públicos",
- "admin.analytics.recentActive": "Usuarios Recientemente Activos",
- "admin.analytics.textPosts": "Mensajes de sólo Texto",
- "admin.analytics.title": "Estadísticas para {title}",
- "admin.analytics.totalFilePosts": "Mensajes con Archivos",
- "admin.analytics.totalHashtagPosts": "Mensajes con Hashtags",
- "admin.analytics.totalIncomingWebhooks": "Webhooks de Entrada",
- "admin.analytics.totalOutgoingWebhooks": "Webhooks de Salida",
- "admin.analytics.totalPosts": "Total de Mensajes",
- "admin.analytics.totalUsers": "Total de Usuarios",
"admin.audits.reload": "Recargar",
"admin.audits.title": "Auditorías del Servidor",
"admin.email.allowEmailSignInDescription": "Cuando es verdadero, Mattermost permite a los usuarios iniciar sesión utilizando el correo electrónico y contraseña.",
@@ -294,6 +277,9 @@
"admin.service.attemptTitle": "Máximo de intentos de conexión:",
"admin.service.cmdsDesc": "Cuando es verdadero, se permite la creación de comandos de barra por usuarios.",
"admin.service.cmdsTitle": "Habilitar Comandos de Barra: ",
+ "admin.service.corsEx": "http://ejemplo.com https://ejemplo.com",
+ "admin.service.corsDescription": "Habilita las solicitudes HTTP de origen cruzado para dominios en específico (separados por un espacio). Utiliza \"*\" si quieres habilitar CORS desde cualquier dominio o deja el campo en blanco para deshabilitarlo.",
+ "admin.service.corsTitle": "Permitir Solicitudes de Origen Cruzado desde:",
"admin.service.developerDesc": "(Opción de Desarrollador) Cuando está asignado en verdadero, información extra sobre errores se muestra en el UI.",
"admin.service.developerTitle": "Habilitar modo de Desarrollador: ",
"admin.service.false": "falso",
@@ -441,6 +427,33 @@
"admin.user_item.resetPwd": "Reiniciar Contraseña",
"admin.user_item.sysAdmin": "Admin de Sistema",
"admin.user_item.teamAdmin": "Admin de Equipo",
+ "analytics.chart.loading": "Cargando...",
+ "analytics.chart.meaningful": "No hay suficiente data para tener una representación significativa.",
+ "analytics.system.activeUsers": "Usuarios Activos con Mensajes",
+ "analytics.system.channelTypes": "Tipos de Canales",
+ "analytics.system.postTypes": "Mesajes, Archivos y Hashtags",
+ "analytics.system.privateGroups": "Grupos Privados",
+ "analytics.system.publicChannels": "Canales Públicos",
+ "analytics.system.textPosts": "Mensajes de sólo Texto",
+ "analytics.system.title": "Estadísticas del Sistema",
+ "analytics.system.totalChannels": "Total de Canales",
+ "analytics.system.totalCommands": "Total de Comandos",
+ "analytics.system.totalFilePosts": "Mensajes con Archivos",
+ "analytics.system.totalHashtagPosts": "Mensajes con Hashtags",
+ "analytics.system.totalIncomingWebhooks": "Webhooks de Entrada",
+ "analytics.system.totalOutgoingWebhooks": "Webhooks de Salida",
+ "analytics.system.totalPosts": "Total de Mensajes",
+ "analytics.system.totalSessions": "Total de Sesiones",
+ "analytics.system.totalTeams": "Total de Equipos",
+ "analytics.system.totalUsers": "Total de Usuarios",
+ "analytics.team.activeUsers": "Usuarios Activos con Mensajes",
+ "analytics.team.newlyCreated": "Nuevos Usuarios Creados",
+ "analytics.team.privateGroups": "Grupos Privados",
+ "analytics.team.publicChannels": "Canales Públicos",
+ "analytics.team.recentActive": "Usuarios Recientemente Activos",
+ "analytics.team.title": "Estádisticas del Equipo {team}",
+ "analytics.team.totalPosts": "Total de Mensajes",
+ "analytics.team.totalUsers": "Total de Usuarios",
"audit_table.accountActive": "Cuentas activadas",
"audit_table.accountInactive": "Cuentas desactivadas",
"audit_table.action": "Acción",
@@ -534,6 +547,7 @@
"channel_info.notFound": "Canal no encontrado",
"channel_info.purpose": "Propósito del Canal:",
"channel_info.url": "URL del Canal:",
+ "channel_invite.add": " Agregar",
"channel_invite.addNewMembers": "Agregar nuevos Miembros a ",
"channel_invite.close": "Cerrar",
"channel_loader.connection_error": "Parece haber un problema con tu conexión a internet.",
@@ -547,6 +561,7 @@
"channel_loader.wrote": " escribió: ",
"channel_members_modal.addNew": " Agregar nuevos Miembros",
"channel_members_modal.close": "Cerrar",
+ "channel_members_modal.removeMember": "Elminar Miembro",
"channel_memebers_modal.members": " Miembros",
"channel_modal.cancel": "Cancelar",
"channel_modal.channel": "Canal",
@@ -654,6 +669,9 @@
"file_upload.filesAbove": "No se pueden subir archivos de más de {max}MB: {filenames}",
"file_upload.limited": "Se pueden subir un máximo de {count} archivos. Por favor envía otros mensajes para adjuntar más archivos.",
"file_upload.pasted": "Imagen Pegada el ",
+ "filtered_user_list.count": "{count, number} {count, plural, one {Miembro} other {Miembros}}",
+ "filtered_user_list.countTotal": "{count, number} {count, plural, one {Miembro} other {Miembros}} de {total} Total",
+ "filtered_user_list.search": "Buscar miembros",
"find_team.email": "Correo electrónico",
"find_team.findDescription": "Enviamos un correo electrónico con los equipos a los que perteneces.",
"find_team.findTitle": "Encuentra tu equipo",
@@ -750,23 +768,9 @@
"login_username.username": "Nombre de usuario",
"login_username.usernameReq": "El nombre de usuario es obligatorio",
"login_username.verifyEmailError": "Por favor válida tu dirección de correo electrónico. Te hemos enviado un correo, revisa tu bandeja de entrada.",
- "member_item.add": " Agregar",
"member_item.makeAdmin": "Convertir en Admin de Equipo",
"member_item.member": "Miembro",
- "member_item.removeMember": "Elminar Miembro",
"member_list.noUsersAdd": "No hay usuarios que agregar.",
- "member_team_item.confirmDemoteDescription": "Si te degradas a ti mismo de la función de Administrador de Sistema y no hay otro usuario con privilegios de Administrador de Sistema, tendrás que volver a asignar un Administrador del Sistema accediendo al servidor de Mattermost a través de un terminal y ejecutar el siguiente comando.",
- "member_team_item.confirmDemoteRoleTitle": "Confirmar el decenso del rol de Administrador de Sistema",
- "member_team_item.confirmDemotion": "Confirmar decenso",
- "member_team_item.confirmDemotionCmd": "platform -assign_role -team_name=\"tuequipo\" -email=\"nombre@tuempresa.com\" -role=\"system_admin\"",
- "member_team_item.inactive": "Inactivo",
- "member_team_item.makeActive": "Activar",
- "member_team_item.makeAdmin": "Convertir a Admin de Equipo",
- "member_team_item.makeInactive": "Desactivar",
- "member_team_item.makeMember": "Convertir en Miembro",
- "member_team_item.member": "Miembro",
- "member_team_item.systemAdmin": "Administrador de Sistema",
- "member_team_item.teamAdmin": "Admin de Equipo",
"members_popover.msg": "Mensaje",
"members_popover.title": "Miembros",
"more_channels.close": "Cerrar",
@@ -776,12 +780,7 @@
"more_channels.noMore": "No hay más canales para unirse",
"more_channels.title": "Más Canales",
"more_direct_channels.close": "Cerrar",
- "more_direct_channels.count": "{count} {member}",
- "more_direct_channels.countTotal": "{count} {member} de {total} Total",
- "more_direct_channels.member": "Miembro",
"more_direct_channels.message": "Mensaje",
- "more_direct_channels.notFound": "No se encontraron usuarios :(",
- "more_direct_channels.search": "Buscar miembros",
"more_direct_channels.title": "Mensajes Directos",
"msg_typing.areTyping": "{users} y {last} están escribiendo...",
"msg_typing.isTyping": "{user} está escribiendo...",
@@ -983,6 +982,18 @@
"team_import_tab.summary": "Ver Resumen",
"team_member_modal.close": "Cerrar",
"team_member_modal.members": "{team} Miembros",
+ "team_members_modal.confirmDemoteDescription": "Si te degradas a ti mismo de la función de Administrador de Sistema y no hay otro usuario con privilegios de Administrador de Sistema, tendrás que volver a asignar un Administrador del Sistema accediendo al servidor de Mattermost a través de un terminal y ejecutar el siguiente comando.",
+ "team_members_modal.confirmDemoteRoleTitle": "Confirmar el decenso del rol de Administrador de Sistema",
+ "team_members_modal.confirmDemotion": "Confirmar decenso",
+ "team_members_modal.confirmDemotionCmd": "platform -assign_role -team_name=\"tuequipo\" -email=\"nombre@tuempresa.com\" -role=\"system_admin\"",
+ "team_members_modal.inactive": "Inactivo",
+ "team_members_modal.makeActive": "Activar",
+ "team_members_modal.makeAdmin": "Convertir a Admin de Equipo",
+ "team_members_modal.makeInactive": "Desactivar",
+ "team_members_modal.makeMember": "Convertir en Miembro",
+ "team_members_modal.member": "Miembro",
+ "team_members_modal.systemAdmin": "Administrador de Sistema",
+ "team_members_modal.teamAdmin": "Admin de Equipo",
"team_settings_modal.exportTab": "Exportar",
"team_settings_modal.generalTab": "General",
"team_settings_modal.importTab": "Importar",
@@ -1217,7 +1228,6 @@
"user.settings.integrations.outWebhooks": "Webhooks de salida",
"user.settings.integrations.outWebhooksDescription": "Administra tus webhooks de salida",
"user.settings.integrations.title": "Configuraciones de Integración",
- "user.settings.languages": "Cambiar Idioma",
"user.settings.languages.change": "Cambia el idioma con el que se muestra la intefaz de usuario",
"user.settings.modal.advanced": "Avanzada",
"user.settings.modal.confirmBtns": "Sí, Descartar",
@@ -1272,6 +1282,7 @@
"user.settings.security.switchGoogle": "Cambiar para utilizar Google SSO",
"user.settings.security.title": "Configuración de Seguridad",
"user.settings.security.viewHistory": "Visualizar historial de acceso",
+ "user_list.notFound": "No se encontraron usuarios :(",
"user_profile.notShared": "Correo no compartido",
"view_image.loading": "Cargando ",
"view_image_popover.download": "Descargar",
diff --git a/web/static/i18n/pt.json b/web/static/i18n/pt.json
index 09a603253..f79dae461 100644
--- a/web/static/i18n/pt.json
+++ b/web/static/i18n/pt.json
@@ -21,23 +21,33 @@
"activity_log_modal.android": "Android",
"activity_log_modal.androidNativeApp": "App Nativo Android",
"activity_log_modal.iphoneNativeApp": "App Nativo para iPhone",
- "admin.analytics.activeUsers": "Usuários Ativos Com Postagens",
- "admin.analytics.channelTypes": "Tipos de Canal",
- "admin.analytics.loading": "Carregando...",
- "admin.analytics.meaningful": "Não há dados suficientes para uma representação significativa.",
- "admin.analytics.newlyCreated": "Novos Usuários Criados",
- "admin.analytics.postTypes": "Postagens, Arquivos e Hashtags",
- "admin.analytics.privateGroups": "Grupos Privados",
- "admin.analytics.publicChannels": "Canais Publicos",
- "admin.analytics.recentActive": "Usuários Ativos Recentes",
- "admin.analytics.textPosts": "Post com texto somente",
- "admin.analytics.title": "Estatísticas para {title}",
- "admin.analytics.totalFilePosts": "Posts com Arquivos",
- "admin.analytics.totalHashtagPosts": "Posts com Hashtags",
- "admin.analytics.totalIncomingWebhooks": "Webhooks Entrada",
- "admin.analytics.totalOutgoingWebhooks": "Webhooks Saída",
- "admin.analytics.totalPosts": "Total Posts",
- "admin.analytics.totalUsers": "Total de Usuários",
+ "analytics.chart.loading": "Carregando...",
+ "analytics.chart.meaningful": "Não há dados suficientes para uma representação significativa.",
+ "analytics.system.activeUsers": "Usuários Ativos Com Posts",
+ "analytics.system.channelTypes": "Tipos de Canal",
+ "analytics.system.postTypes": "Posts, Arquivos e Hashtags",
+ "analytics.system.privateGroups": "Grupos Privados",
+ "analytics.system.publicChannels": "Canais Públicos",
+ "analytics.system.textPosts": "Post com Texto somente",
+ "analytics.system.title": "Estatísticas do Sistema",
+ "analytics.system.totalFilePosts": "Posts com Arquivos",
+ "analytics.system.totalHashtagPosts": "Posts com Hashtags",
+ "analytics.system.totalIncomingWebhooks": "Webhooks Entrada",
+ "analytics.system.totalOutgoingWebhooks": "Webhooks Saída",
+ "analytics.system.totalCommands": "Total de Comandos",
+ "analytics.system.totalSessions": "Total de Sessões",
+ "analytics.system.totalPosts": "Total Posts",
+ "analytics.system.totalUsers": "Total de Usuários",
+ "analytics.system.totalTeams": "Total de Equipes",
+ "analytics.system.totalChannels": "Total de Canais",
+ "analytics.team.activeUsers": "Usuários Ativos Com Posts",
+ "analytics.team.recentActive": "Usuários Ativos Recentes",
+ "analytics.team.newlyCreated": "Novos Usuários Criados",
+ "analytics.team.privateGroups": "Grupos Privados",
+ "analytics.team.publicChannels": "Canais Públicos",
+ "analytics.team.title": "Estatísticas de Equipe para {team}",
+ "analytics.team.totalPosts": "Total Posts",
+ "analytics.team.totalUsers": "Total de Usuários",
"admin.audits.reload": "Recarregar",
"admin.audits.title": "Atividade de Usuário",
"admin.email.allowEmailSignInDescription": "Quando verdadeiro, Mattermost permite aos usuários fazer login usando o e-mail e senha.",
@@ -97,7 +107,7 @@
"admin.email.testing": "Testando...",
"admin.email.true": "verdadeiro",
"admin.gitab.clientSecretDescription": "Obter este valor de acordo com as instruções acima para logar no GitLab.",
- "admin.gitlab.EnableHtmlDesc": "<ol><li>Faça login na sua conta do GitLab e vá para Configurações do perfil -> Aplicativos.</li><li>Digite redirecionamento URIs \"<your-mattermost-url>/login/gitlab/complete\" (exemplo: http://localhost:8065/login/gitlab/complete) e \"<your-mattermost-url>/signup/gitlab/complete\".</li><li>Em seguida, use os campos \"Secret\" e \"Id\" do Gitlab para completar as opções abaixo.</li><li>Complete o Endpoint com as URLs abaixo. </li></ol>",
+ "admin.gitlab.EnableHtmlDesc": "<ol><li>Faça login na sua conta do GitLab e vá para Configurações do Perfil -> Aplicativos.</li><li>Digite redirecionamento URIs \"<your-mattermost-url>/login/gitlab/complete\" (exemplo: http://localhost:8065/login/gitlab/complete) e \"<your-mattermost-url>/signup/gitlab/complete\".</li><li>Em seguida, use os campos \"Secret\" e \"Id\" do Gitlab para completar as opções abaixo.</li><li>Complete o Endpoint com as URLs abaixo. </li></ol>",
"admin.gitlab.authDescription": "Entre https://<your-gitlab-url>/oauth/authorize (exemplo https://example.com:3000/oauth/authorize). Tenha certeza de usar HTTP ou HTTPS na sua URL dependendo da configuração do seu servidor.",
"admin.gitlab.authExample": "Ex \"\"",
"admin.gitlab.authTitle": "Autenticação Endpoint:",
@@ -148,7 +158,7 @@
"admin.image.profileWidthDescription": "Largura do imagem do perfil.",
"admin.image.profileWidthExample": "Ex \"1024\"",
"admin.image.profileWidthTitle": "Perfil Largura:",
- "admin.image.publicLinkDescription": "32-caracteres salt adicionados a assinatura de links de imagens publicas. Aleatoriamente gerados na instalação. Click \"Re-Gerar\" para criar um novo salt.",
+ "admin.image.publicLinkDescription": "32-caracteres salt adicionados a assinatura de links de imagens públicas. Aleatoriamente gerados na instalação. Click \"Re-Gerar\" para criar um novo salt.",
"admin.image.publicLinkExample": "Ex \"gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6\"",
"admin.image.publicLinkTitle": "Link Público Salt:",
"admin.image.regenerate": "Re-Gerar",
@@ -534,6 +544,7 @@
"channel_info.notFound": "Nenhum Canal Encontrado",
"channel_info.purpose": "Propósito do Canal:",
"channel_info.url": "URL do Canal:",
+ "channel_invite.add": " Adicionar",
"channel_invite.addNewMembers": "Adicionar Novo Membro para ",
"channel_invite.close": "Fechar",
"channel_loader.posted": "Postado",
@@ -543,8 +554,11 @@
"channel_loader.uploadedFile": " enviado um arquivo",
"channel_loader.uploadedImage": " enviado uma imagem",
"channel_loader.wrote": " escreveu: ",
+ "channel_loader.connection_error": "Parece existir um problema com a sua conexão de internet.",
+ "channel_loader.unknown_error": "Recebido um código de status inesperado do servidor.",
"channel_members_modal.addNew": " Adicionar Novos Membros",
"channel_members_modal.close": "Fechar",
+ "channel_members_modal.removeMember": "Remover Membro",
"channel_memebers_modal.members": " Membros",
"channel_modal.cancel": "Cancelar",
"channel_modal.channel": "Canal",
@@ -560,7 +574,7 @@
"channel_modal.privateGroup1": "Criar um novo grupo privado com membros restritos. ",
"channel_modal.privateGroup2": "Criar um grupo privado",
"channel_modal.publicChannel1": "Criar um canal público",
- "channel_modal.publicChannel2": "Criar um novo canal publico para qualquer um participar. ",
+ "channel_modal.publicChannel2": "Criar um novo canal público para qualquer um participar. ",
"channel_modal.purpose": "Propósito",
"channel_notifications.allActivity": "Para todas as atividades",
"channel_notifications.allUnread": "Para todas as mensagens não lidas",
@@ -652,6 +666,9 @@
"file_upload.filesAbove": "Arquivos acima {max}MB não podem ser enviados: {filenames}",
"file_upload.limited": "Limite máximo de uploads de {count} arquivos. Por favor use um post adicional para mais arquivos.",
"file_upload.pasted": "Imagem Colada em ",
+ "filtered_user_list.count": "{count, number} {count, plural, one {Membro} other {Membros}}",
+ "filtered_user_list.countTotal": "{count, number} {count, plural, one {Membro} other {Membros}} de {total} Total",
+ "filtered_user_list.search": "Procurar membros",
"find_team.email": "E-mail",
"find_team.findDescription": "Foi enviado um e-mail com links para todas as equipes do qual você é membro.",
"find_team.findTitle": "Encontre Sua Equipe",
@@ -748,22 +765,8 @@
"login_username.username": "Usuário",
"login_username.usernameReq": "Um nome de usuário é necessário",
"login_username.verifyEmailError": "Por favor verifique seu endereço de email. Verifique por um email na sua caixa de entrada.",
- "member_item.add": " Adicionar",
"member_item.makeAdmin": "Tornar Admin",
"member_item.member": "Membro",
- "member_item.removeMember": "Remover Membro",
- "member_team_item.confirmDemoteDescription": "Se você rebaixar você mesmo de Admin de Sistema e não exista outro usuário como privilegios de Admin de Sistema, você precisa-rá re-inscrever um Admin de Sistema acessando o servidor Mattermost através do terminal e executando o seguinte comando.",
- "member_team_item.confirmDemoteRoleTitle": "Confirmar o rebaixamento de Admin Sistema",
- "member_team_item.confirmDemotion": "Confirmar Rebaixamento",
- "member_team_item.confirmDemotionCmd": "platform -assign_role -team_name=\"yourteam\" -email=\"name@yourcompany.com\" -role=\"system_admin\"",
- "member_team_item.inactive": "Inativo",
- "member_team_item.makeActive": "Tornar Ativo",
- "member_team_item.makeAdmin": "Tornar Admin de Equipe",
- "member_team_item.makeInactive": "Tornar Inativo",
- "member_team_item.makeMember": "Tornar Membro",
- "member_team_item.member": "Membro",
- "member_team_item.systemAdmin": "Admin do Sistema",
- "member_team_item.teamAdmin": "Admin Equipe",
"members_popover.msg": "Mensagem",
"members_popover.title": "Membros",
"more_channels.close": "Fechar",
@@ -773,12 +776,7 @@
"more_channels.noMore": "Não há mais canais para participar",
"more_channels.title": "Mais Canais",
"more_direct_channels.close": "Fechar",
- "more_direct_channels.count": "{count} {member}",
- "more_direct_channels.countTotal": "{count} {member} de {total} Total",
- "more_direct_channels.member": "Membro",
"more_direct_channels.message": "Mensagem",
- "more_direct_channels.notFound": "Nenhum usuário encontrado :(",
- "more_direct_channels.search": "Procurar membros",
"more_direct_channels.title": "Mensagens Diretas",
"msg_typing.areTyping": "{users} e {last} estão digitando...",
"msg_typing.isTyping": "{user} está digitando...",
@@ -913,7 +911,7 @@
"sidebar.pg": "Grupos Privados",
"sidebar.removeList": "Remover da lista",
"sidebar.tutorialScreen1": "<h4>Canais</h4><p><strong>Canais</strong> organizar conversas em diferentes tópicos. Eles estão abertos a todos em sua equipe. Para enviar comunicações privadas utilize <strong>Mensagens Diretas</strong> para uma única pessoa ou <strong>Grupos Privados</strong> para várias pessoas.</p>",
- "sidebar.tutorialScreen2": "<h4>Canais \"Town Square\" e \"Off-Topic\"</h4><p>Aqui estão dois canais publicos para começar:</p><p><strong>Town Square</strong> é um lugar comunicação de toda equipe. Todo mundo em sua equipe é um mendro deste canal.</p><p><strong>Off-Topic</strong> é um lugar para diversão e humor fora dos canais relacionados com o trabalho. Você e sua equipe podem decidir qual outros canais serão criados.</p>",
+ "sidebar.tutorialScreen2": "<h4>Canais \"Town Square\" e \"Off-Topic\"</h4><p>Aqui estão dois canais públicos para começar:</p><p><strong>Town Square</strong> é um lugar comunicação de toda equipe. Todo mundo em sua equipe é um membro deste canal.</p><p><strong>Off-Topic</strong> é um lugar para diversão e humor fora dos canais relacionados com o trabalho. Você e sua equipe podem decidir qual outros canais serão criados.</p>",
"sidebar.tutorialScreen3": "<h4>Criando e participando de Canais</h4><p>Clique em <strong>\"Mais...\"</strong> para criar um novo canal ou participar de um já existente.</p><p>Você também pode criar um novo canal ou grupo privado ao clicar em <strong>no símbolo \"+\"</strong> ao lado do canal ou grupo privado no cabeçalho.</p>",
"sidebar.unreadAbove": "Post(s) não lidos abaixo",
"sidebar.unreadBelow": "Post(s) não lidos abaixo",
@@ -963,8 +961,8 @@
"sso_signup.team_error": "Por favor entre o nome da equipe",
"suggestion.mention.all": "Notificar todo mundo na equipe",
"suggestion.mention.channel": "Notifica todos no canal",
- "suggestion.search.private": "Grupos Publicos",
- "suggestion.search.public": "Canais Publicos",
+ "suggestion.search.private": "Grupos Públicos",
+ "suggestion.search.public": "Canais Públicos",
"team_export_tab.download": "download",
"team_export_tab.export": "Exportar",
"team_export_tab.exportTeam": "Exportar sua equipe",
@@ -973,13 +971,25 @@
"team_export_tab.unable": " Não foi possível exportar: {error}",
"team_import_tab.failure": " Falha na importação: ",
"team_import_tab.import": "Importar",
- "team_import_tab.importHelp": "<p>Para importar uma equipe do Slack, vá para a Slack > Definições de Equipe > Importar/Exportar Dados > Exportar > Iniciar Exportação. Slack não permite que você exporte arquivos, imagens, grupos privados ou as mensagens diretas armazenadas no Slack. Portanto, Importação Slack para Mattermost só suporta a importação de mensagens de texto dos canais publicos da sua equipe Slack.</p><p>O Importação Slack para Mattermost está em 'Beta'. Slack bot posts ainda não pode ser importado e Slack @mentions não são suportados no momento.</p>",
+ "team_import_tab.importHelp": "<p>Para importar uma equipe do Slack, vá para a Slack > Definições de Equipe > Importar/Exportar Dados > Exportar > Iniciar Exportação. Slack não permite que você exporte arquivos, imagens, grupos privados ou as mensagens diretas armazenadas no Slack. Portanto, Importação Slack para Mattermost só suporta a importação de mensagens de texto dos canais públicos da sua equipe Slack.</p><p>O Importação Slack para Mattermost está em 'Beta'. Slack bot posts ainda não pode ser importado e Slack @mentions não são suportados no momento.</p>",
"team_import_tab.importSlack": "Importar do Slack (Beta)",
"team_import_tab.importing": " Importando...",
"team_import_tab.successful": " Importado com sucesso: ",
"team_import_tab.summary": "Ver Resumo",
"team_member_modal.close": "Fechar",
"team_member_modal.members": "{team} Membros",
+ "team_members_dropdown.confirmDemoteDescription": "Se você rebaixar você mesmo de Admin de Sistema e não exista outro usuário como privilegios de Admin de Sistema, você precisa-rá re-inscrever um Admin de Sistema acessando o servidor Mattermost através do terminal e executando o seguinte comando.",
+ "team_members_dropdown.confirmDemoteRoleTitle": "Confirmar o rebaixamento de Admin Sistema",
+ "team_members_dropdown.confirmDemotion": "Confirmar Rebaixamento",
+ "team_members_dropdown.confirmDemotionCmd": "platform -assign_role -team_name=\"yourteam\" -email=\"name@yourcompany.com\" -role=\"system_admin\"",
+ "team_members_dropdown.inactive": "Inativo",
+ "team_members_dropdown.makeActive": "Tornar Ativo",
+ "team_members_dropdown.makeAdmin": "Tornar Admin de Equipe",
+ "team_members_dropdown.makeInactive": "Tornar Inativo",
+ "team_members_dropdown.makeMember": "Tornar Membro",
+ "team_members_dropdown.member": "Membro",
+ "team_members_dropdown.systemAdmin": "Admin do Sistema",
+ "team_members_dropdown.teamAdmin": "Admin Equipe",
"team_settings_modal.exportTab": "Exportar",
"team_settings_modal.generalTab": "Geral",
"team_settings_modal.importTab": "Importar",
@@ -1030,7 +1040,7 @@
"team_signup_username.reserved": "Este nome de usuário é reservado, por favor, escolha uma nova.",
"team_signup_username.username": "Seu usuário",
"team_signup_welcome.address": "Endereço de E-mail",
- "team_signup_welcome.admin": "Sua conta irá administrar no novo site da equipe. <br />Você pode adicionar outros administradores depois.",
+ "team_signup_welcome.admin": "Sua conta irá administrar o novo site da equipe. <br />Você pode adicionar outros administradores depois.",
"team_signup_welcome.confirm": "Por favor confirme seu endereço de e-mail:",
"team_signup_welcome.different": "Utilize um e-mail diferente",
"team_signup_welcome.instead": "Use este ao invez",
@@ -1047,10 +1057,10 @@
"tutorial_intro.invite": "Convidar pessoas para equipe",
"tutorial_intro.next": "Próximo",
"tutorial_intro.screenOne": "<h3>Bem vindo ao:</h3><h1>Mattermost</h1><p>Sua equipe de comunicação em um só lugar, pesquisas instantâneas disponível em qualquer lugar</p><p>Mantenha sua equipe conectada para ajudá-los a conseguir o que mais importa.</p>",
- "tutorial_intro.screenTwo": "<h3>Como Mattermost funciona:</h3><p>A comunicação acontece em canais de discussão pública, grupos privados e mensagens diretas.</p><p>Tudo é arquivadas e pesquisável a partir de qualquer desktop, laptop ou telefone com suporte a web.</p>",
+ "tutorial_intro.screenTwo": "<h3>Como Mattermost funciona:</h3><p>A comunicação acontece em canais de discussão pública, grupos privados e mensagens diretas.</p><p>Tudo é arquivado e pesquisável a partir de qualquer desktop, laptop ou telefone com suporte a web.</p>",
"tutorial_intro.skip": "Pular o tutorial",
"tutorial_intro.support": "Precisa de alguma coisa, envie um e-mail para nós no ",
- "tutorial_intro.teamInvite": "Convite de Equipe",
+ "tutorial_intro.teamInvite": "Convide sua Equipe",
"tutorial_intro.whenReady": " quando você estiver pronto.",
"tutorial_tip.next": "Próximo",
"tutorial_tip.ok": "Ok",
@@ -1058,6 +1068,7 @@
"tutorial_tip.seen": "Viu isso antes? ",
"upload_overlay.info": "Soltar um arquivo para enviá-lo.",
"user.settings.advance.embed_preview": "Mostrar pré-visualização dos links abaixo da mensagem",
+ "user.settings.advance.embed_toggle": "Exibir mostrar/esconder para todas as pre-visualizações",
"user.settings.advance.enabled": "habilitado",
"user.settings.advance.feature": " Recursos ",
"user.settings.advance.features": " Recursos ",
@@ -1070,6 +1081,7 @@
"user.settings.advance.sendTitle": "Enviar mensagens Ctrl + Enter",
"user.settings.advance.title": "Configurações Avançadas",
"user.settings.cmds.add": "Adicionar",
+ "user.settings.cmds.add_desc": "Criar comandos slash para enviar eventos para integrações externas e receber uma resposta. Por exemplo digitando `/patient Joe Smith` poderia trazer de volta os resultados de pesquisa a partir do seu sistema de gestão de registos internos de saúde para o nome “Joe Smith”. Por favor veja <a href=\"http://docs.mattermost.com/developer/slash-commands.html\">Documentação comandos Slash</a> para detalhes e instruções. Ver todos os comandos slash configurados nesta equipe abaixo.",
"user.settings.cmds.add_display_name.placeholder": "Exemplo: \"Procurar registros de pacientes\"",
"user.settings.cmds.add_new": "Adicionar um novo comando",
"user.settings.cmds.add_trigger.placeholder": "Comando de gatilho ex. \"hello\", não incluí a barra",
@@ -1182,9 +1194,11 @@
"user.settings.hooks_in.add": "Adicionar",
"user.settings.hooks_in.addTitle": "Adicionar um novo webhook entrada",
"user.settings.hooks_in.channel": "Canal: ",
+ "user.settings.hooks_in.description": "Criar URLs webhook para usar em integrações externas. Por favor veja <a href=\"http://docs.mattermost.com/developer/webhooks-incoming.html\" target=\"_blank\">documentação webhook entrada</a> para saber mais. Ver todos os webhooks de entrada configurados nesta equipe abaixo.",
"user.settings.hooks_in.existing": "Webhooks de entrada existentes",
"user.settings.hooks_in.none": "Nenhum",
"user.settings.hooks_out.add": "Adicionar",
+ "user.settings.hooks_out.addDescription": "Criar webhooks para enviar novos mensagens de eventos para uma integração externa. Por favor veja <a href=\"http://docs.mattermost.com/developer/webhooks-outgoing.html\" target=\"_blank\">documentação webhook saída</a> para saber mais. Ver todos os webhooks de saída desta equipe configurados abaixo.",
"user.settings.hooks_out.addTitle": "Adicionar um novo webhook saída",
"user.settings.hooks_out.callback": "Callback URLs: ",
"user.settings.hooks_out.callbackDesc": "Nova linha separada de URLs que receberá o evento HTTP POST",
@@ -1210,7 +1224,6 @@
"user.settings.integrations.outWebhooks": "Webhooks Saída",
"user.settings.integrations.outWebhooksDescription": "Gerencie seus webhooks saída",
"user.settings.integrations.title": "Configuração de Integração",
- "user.settings.languages": "Definir idioma",
"user.settings.languages.change": "Alterar o idioma da interface",
"user.settings.modal.advanced": "Avançado",
"user.settings.modal.confirmBtns": "Sim, Descartar",
@@ -1265,6 +1278,7 @@
"user.settings.security.switchGoogle": "Trocar para usar Google SSO",
"user.settings.security.title": "Configurações de Segurança",
"user.settings.security.viewHistory": "Ver Histórico de Acesso",
+ "user_list.notFound": "Nenhum usuário encontrado :(",
"user_profile.notShared": "E-mail não compartilhado",
"view_image.loading": "Carregando ",
"view_image_popover.download": "Download",