[GH-26715] Added Pagination Support for IncomingWebHooks (#27502)

* Added Pagination Support for IncomingWebHooks

* Incorporated feedback from reviews

* Removed trailing spaces

* Restored deleted server en.json entries, fixed order in webapp en.json

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
alexcekay
2024-08-09 22:44:22 +02:00
коммит произвёл GitHub
родитель 1cf6cf4f5c
Коммит 7232b5f002
35 изменённых файлов: 515 добавлений и 97 удалений

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

@@ -83,6 +83,13 @@
description: The ID of the team to get hooks for. description: The ID of the team to get hooks for.
schema: schema:
type: string type: string
- name: include_total_count
description: >-
Appends a total count of returned hooks inside the response object - ex: `{ "incoming_webhooks": [], "total_count": 0 }`.
in: query
schema:
type: boolean
default: false
responses: responses:
"200": "200":
description: Incoming webhooks retrieval successful description: Incoming webhooks retrieval successful

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

@@ -184,6 +184,8 @@ func getIncomingHooks(c *Context, w http.ResponseWriter, r *http.Request) {
hooks []*model.IncomingWebhook hooks []*model.IncomingWebhook
appErr *model.AppError appErr *model.AppError
js []byte
err error
) )
if teamID != "" { if teamID != "" {
@@ -217,7 +219,20 @@ func getIncomingHooks(c *Context, w http.ResponseWriter, r *http.Request) {
return return
} }
js, err := json.Marshal(hooks) if c.Params.IncludeTotalCount {
totalCount, appErr := c.App.GetIncomingWebhooksCount(teamID, userID)
if appErr != nil {
c.Err = appErr
return
}
hooksWithCount := model.IncomingWebhooksWithCount{Webhooks: hooks, TotalCount: totalCount}
js, err = json.Marshal(hooksWithCount)
} else {
js, err = json.Marshal(hooks)
}
if err != nil { if err != nil {
c.Err = model.NewAppError("getIncomingHooks", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) c.Err = model.NewAppError("getIncomingHooks", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return return

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

@@ -304,6 +304,60 @@ func TestGetIncomingWebhooksByTeam(t *testing.T) {
assert.Equal(t, basicHook.Id, filteredHooks[0].Id) assert.Equal(t, basicHook.Id, filteredHooks[0].Id)
} }
func TestGetIncomingWebhooksWithCount(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
BasicClient := th.Client
th.LoginBasic()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true })
defaultRolePermissions := th.SaveDefaultRolePermissions()
defer func() {
th.RestoreDefaultRolePermissions(defaultRolePermissions)
}()
th.AddPermissionToRole(model.PermissionManageIncomingWebhooks.Id, model.TeamAdminRoleId)
th.AddPermissionToRole(model.PermissionManageIncomingWebhooks.Id, model.SystemUserRoleId)
// Basic user webhook
bHook := &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, TeamId: th.BasicTeam.Id, UserId: th.BasicUser.Id}
basicHook, _, err := BasicClient.CreateIncomingWebhook(context.Background(), bHook)
require.NoError(t, err)
basicHooksWithCount, _, err := BasicClient.GetIncomingWebhooksWithCount(context.Background(), 0, 1000, "")
require.NoError(t, err)
assert.Equal(t, 1, len(basicHooksWithCount.Webhooks))
assert.Equal(t, int64(1), basicHooksWithCount.TotalCount)
assert.Equal(t, basicHook.Id, basicHooksWithCount.Webhooks[0].Id)
// Admin User webhook
aHook := &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, TeamId: th.BasicTeam.Id, UserId: th.SystemAdminUser.Id}
adminHook, _, err := th.SystemAdminClient.CreateIncomingWebhook(context.Background(), aHook)
require.NoError(t, err)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
adminHooksWithCount, _, err2 := client.GetIncomingWebhooksWithCount(context.Background(), 0, 1000, "")
require.NoError(t, err2)
assert.Equal(t, 2, len(adminHooksWithCount.Webhooks))
assert.Equal(t, int64(2), adminHooksWithCount.TotalCount)
foundBasicHook := false
foundAdminHook := false
for _, h := range adminHooksWithCount.Webhooks {
if basicHook.Id == h.Id {
foundBasicHook = true
}
if adminHook.Id == h.Id {
foundAdminHook = true
}
}
require.True(t, foundBasicHook, "missing basic user hook")
require.True(t, foundAdminHook, "missing admin user hook")
})
}
func TestGetIncomingWebhook(t *testing.T) { func TestGetIncomingWebhook(t *testing.T) {
th := Setup(t).InitBasic() th := Setup(t).InitBasic()
defer th.TearDown() defer th.TearDown()

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

@@ -241,7 +241,7 @@ func (a *App) getAnalytics(rctx request.CTX, name string, teamID string, forSupp
var incomingWebhookCount int64 var incomingWebhookCount int64
g2.Go(func() error { g2.Go(func() error {
var err error var err error
if incomingWebhookCount, err = a.Srv().Store().Webhook().AnalyticsIncomingCount(teamID); err != nil { if incomingWebhookCount, err = a.Srv().Store().Webhook().AnalyticsIncomingCount(teamID, ""); err != nil {
return model.NewAppError("GetAnalytics", "app.webhooks.analytics_incoming_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err) return model.NewAppError("GetAnalytics", "app.webhooks.analytics_incoming_count.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
} }
return nil return nil

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

@@ -717,6 +717,7 @@ type AppIface interface {
GetGroupsByUserId(userID string) ([]*model.Group, *model.AppError) GetGroupsByUserId(userID string) ([]*model.Group, *model.AppError)
GetHubForUserId(userID string) *platform.Hub GetHubForUserId(userID string) *platform.Hub
GetIncomingWebhook(hookID string) (*model.IncomingWebhook, *model.AppError) GetIncomingWebhook(hookID string) (*model.IncomingWebhook, *model.AppError)
GetIncomingWebhooksCount(teamID string, userID string) (int64, *model.AppError)
GetIncomingWebhooksForTeamPage(teamID string, page, perPage int) ([]*model.IncomingWebhook, *model.AppError) GetIncomingWebhooksForTeamPage(teamID string, page, perPage int) ([]*model.IncomingWebhook, *model.AppError)
GetIncomingWebhooksForTeamPageByUser(teamID string, userID string, page, perPage int) ([]*model.IncomingWebhook, *model.AppError) GetIncomingWebhooksForTeamPageByUser(teamID string, userID string, page, perPage int) ([]*model.IncomingWebhook, *model.AppError)
GetIncomingWebhooksPage(page, perPage int) ([]*model.IncomingWebhook, *model.AppError) GetIncomingWebhooksPage(page, perPage int) ([]*model.IncomingWebhook, *model.AppError)

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

@@ -7220,6 +7220,28 @@ func (a *OpenTracingAppLayer) GetIncomingWebhook(hookID string) (*model.Incoming
return resultVar0, resultVar1 return resultVar0, resultVar1
} }
func (a *OpenTracingAppLayer) GetIncomingWebhooksCount(teamID string, userID string) (int64, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetIncomingWebhooksCount")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0, resultVar1 := a.app.GetIncomingWebhooksCount(teamID, userID)
if resultVar1 != nil {
span.LogFields(spanlog.Error(resultVar1))
ext.Error.Set(span, true)
}
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) GetIncomingWebhooksForTeamPage(teamID string, page int, perPage int) ([]*model.IncomingWebhook, *model.AppError) { func (a *OpenTracingAppLayer) GetIncomingWebhooksForTeamPage(teamID string, page int, perPage int) ([]*model.IncomingWebhook, *model.AppError) {
origCtx := a.ctx origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetIncomingWebhooksForTeamPage") span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetIncomingWebhooksForTeamPage")

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

@@ -507,6 +507,19 @@ func (a *App) GetIncomingWebhooksPage(page, perPage int) ([]*model.IncomingWebho
return a.GetIncomingWebhooksPageByUser("", page, perPage) return a.GetIncomingWebhooksPageByUser("", page, perPage)
} }
func (a *App) GetIncomingWebhooksCount(teamID string, userID string) (int64, *model.AppError) {
if !*a.Config().ServiceSettings.EnableIncomingWebhooks {
return 0, model.NewAppError("GetIncomingWebhooksCount", "api.incoming_webhook.disabled.app_error", nil, "", http.StatusNotImplemented)
}
totalCount, err := a.Srv().Store().Webhook().AnalyticsIncomingCount(teamID, userID)
if err != nil {
return 0, model.NewAppError("GetIncomingWebhooksCount", "app.webhooks.get_incoming_count.app_error", map[string]any{"TeamID": teamID, "UserID": userID, "Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err)
}
return totalCount, nil
}
func (a *App) CreateOutgoingWebhook(hook *model.OutgoingWebhook) (*model.OutgoingWebhook, *model.AppError) { func (a *App) CreateOutgoingWebhook(hook *model.OutgoingWebhook) (*model.OutgoingWebhook, *model.AppError) {
if !*a.Config().ServiceSettings.EnableOutgoingWebhooks { if !*a.Config().ServiceSettings.EnableOutgoingWebhooks {
return nil, model.NewAppError("CreateOutgoingWebhook", "api.outgoing_webhook.disabled.app_error", nil, "", http.StatusNotImplemented) return nil, model.NewAppError("CreateOutgoingWebhook", "api.outgoing_webhook.disabled.app_error", nil, "", http.StatusNotImplemented)

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

@@ -12908,7 +12908,7 @@ func (s *OpenTracingLayerUserTermsOfServiceStore) Save(userTermsOfService *model
return result, err return result, err
} }
func (s *OpenTracingLayerWebhookStore) AnalyticsIncomingCount(teamID string) (int64, error) { func (s *OpenTracingLayerWebhookStore) AnalyticsIncomingCount(teamID string, userID string) (int64, error) {
origCtx := s.Root.Store.Context() origCtx := s.Root.Store.Context()
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "WebhookStore.AnalyticsIncomingCount") span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "WebhookStore.AnalyticsIncomingCount")
s.Root.Store.SetContext(newCtx) s.Root.Store.SetContext(newCtx)
@@ -12917,7 +12917,7 @@ func (s *OpenTracingLayerWebhookStore) AnalyticsIncomingCount(teamID string) (in
}() }()
defer span.Finish() defer span.Finish()
result, err := s.WebhookStore.AnalyticsIncomingCount(teamID) result, err := s.WebhookStore.AnalyticsIncomingCount(teamID, userID)
if err != nil { if err != nil {
span.LogFields(spanlog.Error(err)) span.LogFields(spanlog.Error(err))
ext.Error.Set(span, true) ext.Error.Set(span, true)

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

@@ -14744,11 +14744,11 @@ func (s *RetryLayerUserTermsOfServiceStore) Save(userTermsOfService *model.UserT
} }
func (s *RetryLayerWebhookStore) AnalyticsIncomingCount(teamID string) (int64, error) { func (s *RetryLayerWebhookStore) AnalyticsIncomingCount(teamID string, userID string) (int64, error) {
tries := 0 tries := 0
for { for {
result, err := s.WebhookStore.AnalyticsIncomingCount(teamID) result, err := s.WebhookStore.AnalyticsIncomingCount(teamID, userID)
if err == nil { if err == nil {
return result, nil return result, nil
} }

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

@@ -352,15 +352,19 @@ func (s SqlWebhookStore) UpdateOutgoing(hook *model.OutgoingWebhook) (*model.Out
return hook, nil return hook, nil
} }
func (s SqlWebhookStore) AnalyticsIncomingCount(teamId string) (int64, error) { func (s SqlWebhookStore) AnalyticsIncomingCount(teamID string, userID string) (int64, error) {
queryBuilder := queryBuilder :=
s.getQueryBuilder(). s.getQueryBuilder().
Select("COUNT(*)"). Select("COUNT(*)").
From("IncomingWebhooks"). From("IncomingWebhooks").
Where("DeleteAt = 0") Where("DeleteAt = 0")
if teamId != "" { if teamID != "" {
queryBuilder = queryBuilder.Where("TeamId", teamId) queryBuilder = queryBuilder.Where(sq.Eq{"TeamId": teamID})
}
if userID != "" {
queryBuilder = queryBuilder.Where(sq.Eq{"UserId": userID})
} }
queryString, args, err := queryBuilder.ToSql() queryString, args, err := queryBuilder.ToSql()

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

@@ -615,7 +615,7 @@ type WebhookStore interface {
PermanentDeleteOutgoingByUser(userID string) error PermanentDeleteOutgoingByUser(userID string) error
UpdateOutgoing(hook *model.OutgoingWebhook) (*model.OutgoingWebhook, error) UpdateOutgoing(hook *model.OutgoingWebhook) (*model.OutgoingWebhook, error)
AnalyticsIncomingCount(teamID string) (int64, error) AnalyticsIncomingCount(teamID string, userID string) (int64, error)
AnalyticsOutgoingCount(teamID string) (int64, error) AnalyticsOutgoingCount(teamID string) (int64, error)
InvalidateWebhookCache(webhook string) InvalidateWebhookCache(webhook string)
ClearCaches() ClearCaches()

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

@@ -14,9 +14,9 @@ type WebhookStore struct {
mock.Mock mock.Mock
} }
// AnalyticsIncomingCount provides a mock function with given fields: teamID // AnalyticsIncomingCount provides a mock function with given fields: teamID, userID
func (_m *WebhookStore) AnalyticsIncomingCount(teamID string) (int64, error) { func (_m *WebhookStore) AnalyticsIncomingCount(teamID string, userID string) (int64, error) {
ret := _m.Called(teamID) ret := _m.Called(teamID, userID)
if len(ret) == 0 { if len(ret) == 0 {
panic("no return value specified for AnalyticsIncomingCount") panic("no return value specified for AnalyticsIncomingCount")
@@ -24,17 +24,17 @@ func (_m *WebhookStore) AnalyticsIncomingCount(teamID string) (int64, error) {
var r0 int64 var r0 int64
var r1 error var r1 error
if rf, ok := ret.Get(0).(func(string) (int64, error)); ok { if rf, ok := ret.Get(0).(func(string, string) (int64, error)); ok {
return rf(teamID) return rf(teamID, userID)
} }
if rf, ok := ret.Get(0).(func(string) int64); ok { if rf, ok := ret.Get(0).(func(string, string) int64); ok {
r0 = rf(teamID) r0 = rf(teamID, userID)
} else { } else {
r0 = ret.Get(0).(int64) r0 = ret.Get(0).(int64)
} }
if rf, ok := ret.Get(1).(func(string) error); ok { if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(teamID) r1 = rf(teamID, userID)
} else { } else {
r1 = ret.Error(1) r1 = ret.Error(1)
} }

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

@@ -577,10 +577,28 @@ func testWebhookStoreCountIncoming(t *testing.T, rctx request.CTX, ss store.Stor
_, _ = ss.Webhook().SaveIncoming(o1) _, _ = ss.Webhook().SaveIncoming(o1)
c, err := ss.Webhook().AnalyticsIncomingCount("") c, err := ss.Webhook().AnalyticsIncomingCount("", "")
require.NoError(t, err) require.NoError(t, err)
require.NotEqual(t, 0, c, "should have at least 1 incoming hook") require.NotEqual(t, 0, c, "should have at least 1 incoming hook")
o2 := &model.IncomingWebhook{}
o2.ChannelId = model.NewId()
o2.UserId = model.NewId()
o2.TeamId = model.NewId()
_, _ = ss.Webhook().SaveIncoming(o2)
c, err = ss.Webhook().AnalyticsIncomingCount("", "")
require.NoError(t, err)
require.NotEqual(t, 1, c, "should have at least 2 incoming hooks")
c, err = ss.Webhook().AnalyticsIncomingCount(o1.TeamId, "")
require.NoError(t, err)
require.NotEqual(t, 0, c, "should have at least 1 incoming hook when filtering by TeamID")
c, err = ss.Webhook().AnalyticsIncomingCount("", o2.UserId)
require.NoError(t, err)
require.NotEqual(t, 0, c, "should have at least 1 incoming hook when filtering by UserID")
} }
func testWebhookStoreCountOutgoing(t *testing.T, rctx request.CTX, ss store.Store) { func testWebhookStoreCountOutgoing(t *testing.T, rctx request.CTX, ss store.Store) {

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

@@ -11618,10 +11618,10 @@ func (s *TimerLayerUserTermsOfServiceStore) Save(userTermsOfService *model.UserT
return result, err return result, err
} }
func (s *TimerLayerWebhookStore) AnalyticsIncomingCount(teamID string) (int64, error) { func (s *TimerLayerWebhookStore) AnalyticsIncomingCount(teamID string, userID string) (int64, error) {
start := time.Now() start := time.Now()
result, err := s.WebhookStore.AnalyticsIncomingCount(teamID) result, err := s.WebhookStore.AnalyticsIncomingCount(teamID, userID)
elapsed := float64(time.Since(start)) / float64(time.Second) elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil { if s.Root.Metrics != nil {

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

@@ -7046,6 +7046,10 @@
"id": "app.webhooks.get_incoming_by_user.app_error", "id": "app.webhooks.get_incoming_by_user.app_error",
"translation": "Unable to get the webhook." "translation": "Unable to get the webhook."
}, },
{
"id": "app.webhooks.get_incoming_count.app_error",
"translation": "Unable to get the webhook for teamID={{.TeamID}}, userID={{.UserID}}, err={{.Error}}."
},
{ {
"id": "app.webhooks.get_outgoing.app_error", "id": "app.webhooks.get_outgoing.app_error",
"translation": "Unable to get the webhook." "translation": "Unable to get the webhook."

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

@@ -351,7 +351,7 @@ func (ts *TelemetryService) trackActivity() {
slashCommandsCount, _ = ts.dbStore.Command().AnalyticsCommandCount("") slashCommandsCount, _ = ts.dbStore.Command().AnalyticsCommandCount("")
if c, err := ts.dbStore.Webhook().AnalyticsIncomingCount(""); err == nil { if c, err := ts.dbStore.Webhook().AnalyticsIncomingCount("", ""); err == nil {
incomingWebhooksCount = c incomingWebhooksCount = c
} }

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

@@ -231,7 +231,7 @@ func initializeMocks(cfg *model.Config, cloudLicense bool) (*mocks.ServerIface,
commandStore.On("AnalyticsCommandCount", "").Return(int64(15), nil) commandStore.On("AnalyticsCommandCount", "").Return(int64(15), nil)
webhookStore := storeMocks.WebhookStore{} webhookStore := storeMocks.WebhookStore{}
webhookStore.On("AnalyticsIncomingCount", "").Return(int64(16), nil) webhookStore.On("AnalyticsIncomingCount", "", "").Return(int64(16), nil)
webhookStore.On("AnalyticsOutgoingCount", "").Return(int64(17), nil) webhookStore.On("AnalyticsOutgoingCount", "").Return(int64(17), nil)
groupStore := storeMocks.GroupStore{} groupStore := storeMocks.GroupStore{}

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

@@ -4983,6 +4983,24 @@ func (c *Client4) GetIncomingWebhooks(ctx context.Context, page int, perPage int
return iwl, BuildResponse(r), nil return iwl, BuildResponse(r), nil
} }
// GetIncomingWebhooksWithCount returns a page of incoming webhooks on the system including the total count. Page counting starts at 0.
func (c *Client4) GetIncomingWebhooksWithCount(ctx context.Context, page int, perPage int, etag string) (*IncomingWebhooksWithCount, *Response, error) {
query := fmt.Sprintf("?page=%v&per_page=%v&include_total_count="+c.boolString(true), page, perPage)
r, err := c.DoAPIGet(ctx, c.incomingWebhooksRoute()+query, etag)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var iwl *IncomingWebhooksWithCount
if r.StatusCode == http.StatusNotModified {
return iwl, BuildResponse(r), nil
}
if err := json.NewDecoder(r.Body).Decode(&iwl); err != nil {
return nil, nil, NewAppError("GetIncomingWebhooksWithCount", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return iwl, BuildResponse(r), nil
}
// GetIncomingWebhooksForTeam returns a page of incoming webhooks for a team. Page counting starts at 0. // GetIncomingWebhooksForTeam returns a page of incoming webhooks for a team. Page counting starts at 0.
func (c *Client4) GetIncomingWebhooksForTeam(ctx context.Context, teamId string, page int, perPage int, etag string) ([]*IncomingWebhook, *Response, error) { func (c *Client4) GetIncomingWebhooksForTeam(ctx context.Context, teamId string, page int, perPage int, etag string) ([]*IncomingWebhook, *Response, error) {
query := fmt.Sprintf("?page=%v&per_page=%v&team_id=%v", page, perPage, teamId) query := fmt.Sprintf("?page=%v&per_page=%v&team_id=%v", page, perPage, teamId)

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

@@ -59,6 +59,11 @@ type IncomingWebhookRequest struct {
Priority *PostPriority `json:"priority"` Priority *PostPriority `json:"priority"`
} }
type IncomingWebhooksWithCount struct {
Webhooks []*IncomingWebhook `json:"incoming_webhooks"`
TotalCount int64 `json:"total_count"`
}
func (o *IncomingWebhook) IsValid() *AppError { func (o *IncomingWebhook) IsValid() *AppError {
if !IsValidId(o.Id) { if !IsValidId(o.Id) {
return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.id.app_error", nil, "", http.StatusBadRequest) return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.id.app_error", nil, "", http.StatusBadRequest)

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

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type {IncomingWebhook, OutgoingWebhook, Command, OAuthApp, OutgoingOAuthConnection} from '@mattermost/types/integrations'; import type {IncomingWebhook, IncomingWebhooksWithCount, OutgoingWebhook, Command, OAuthApp, OutgoingOAuthConnection} from '@mattermost/types/integrations';
import * as IntegrationActions from 'mattermost-redux/actions/integrations'; import * as IntegrationActions from 'mattermost-redux/actions/integrations';
import {getProfilesByIds} from 'mattermost-redux/actions/users'; import {getProfilesByIds} from 'mattermost-redux/actions/users';
@@ -11,11 +11,13 @@ import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
const DEFAULT_PAGE_SIZE = 100; const DEFAULT_PAGE_SIZE = 100;
export function loadIncomingHooksAndProfilesForTeam(teamId: string, page = 0, perPage = DEFAULT_PAGE_SIZE): ActionFuncAsync<IncomingWebhook[]> { export function loadIncomingHooksAndProfilesForTeam(teamId: string, page = 0, perPage = DEFAULT_PAGE_SIZE, includeTotalCount = false): ActionFuncAsync<IncomingWebhook[] | IncomingWebhooksWithCount> {
return async (dispatch) => { return async (dispatch) => {
const {data} = await dispatch(IntegrationActions.getIncomingHooks(teamId, page, perPage)); const {data} = await dispatch(IntegrationActions.getIncomingHooks(teamId, page, perPage, includeTotalCount));
if (data) { if (data) {
dispatch(loadProfilesForIncomingHooks(data)); const isWebhooksWithCount = IntegrationActions.isIncomingWebhooksWithCount(data);
const hooks = isWebhooksWithCount ? (data as IncomingWebhooksWithCount).incoming_webhooks : data;
dispatch(loadProfilesForIncomingHooks(hooks as IncomingWebhook[]));
} }
return {data}; return {data};
}; };

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

@@ -0,0 +1,25 @@
@use 'sass:color';
@import 'utils/variables';
#searchInput {
flex: none;
}
.backstage-footer {
display: flex;
height: auto;
flex-direction: row;
padding: 8px;
border: 1px solid $light-gray;
border-top: none;
background: $white;
color: rgba(0, 0, 0, 0.5);
font-size: 1.1em;
text-align: right;
.backstage-footer__cell {
width: 100%;
text-align: right;
}
}

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

@@ -3,15 +3,20 @@
import React, {useState} from 'react'; import React, {useState} from 'react';
import type {ChangeEvent, ReactNode} from 'react'; import type {ChangeEvent, ReactNode} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import LoadingScreen from 'components/loading_screen'; import LoadingScreen from 'components/loading_screen';
import NextIcon from 'components/widgets/icons/fa_next_icon';
import PreviousIcon from 'components/widgets/icons/fa_previous_icon';
import SearchIcon from 'components/widgets/icons/fa_search_icon'; import SearchIcon from 'components/widgets/icons/fa_search_icon';
import {localizeMessage} from 'utils/utils'; import {localizeMessage} from 'utils/utils';
import './backstage_list.scss';
type Props = { type Props = {
children?: ReactNode | ((filter: string) => void); children?: JSX.Element[] | ((filter: string) => [JSX.Element[], boolean]);
header: ReactNode; header: ReactNode;
addLink?: string; addLink?: string;
addText?: ReactNode; addText?: ReactNode;
@@ -21,23 +26,52 @@ type Props = {
helpText?: ReactNode; helpText?: ReactNode;
loading: boolean; loading: boolean;
searchPlaceholder?: string; searchPlaceholder?: string;
nextPage?: () => void;
previousPage?: () => void;
page?: number;
pageSize?: number;
total?: number;
};
const getPaging = (remainingProps: Props, childCount: number, hasFilter: boolean) => {
const page = (hasFilter || !remainingProps.page) ? 0 : remainingProps.page;
const pageSize = (hasFilter || !remainingProps.pageSize) ? childCount : remainingProps.pageSize;
const total = (hasFilter || !remainingProps.total) ? childCount : remainingProps.total;
let startCount = (page * pageSize) + 1;
let endCount = (page + 1) * pageSize;
endCount = endCount > total ? total : endCount;
if (endCount === 0) {
startCount = 0;
}
const isFirstPage = startCount <= 1;
const isLastPage = endCount >= total;
return {startCount, endCount, total, isFirstPage, isLastPage};
}; };
const BackstageList = ({searchPlaceholder = localizeMessage('backstage_list.search', 'Search'), ...remainingProps}: Props) => { const BackstageList = ({searchPlaceholder = localizeMessage('backstage_list.search', 'Search'), ...remainingProps}: Props) => {
const {formatMessage} = useIntl();
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const updateFilter = (e: ChangeEvent<HTMLInputElement>) => setFilter(e.target.value); const updateFilter = (e: ChangeEvent<HTMLInputElement>) => setFilter(e.target.value);
const filterLowered = filter.toLowerCase(); const filterLowered = filter.toLowerCase();
let children; let children = [];
let childCount = 0;
if (remainingProps.loading) { if (remainingProps.loading) {
children = <LoadingScreen/>; children = [
<LoadingScreen
key='loading'
/>,
];
} else { } else {
children = remainingProps.children;
let hasChildren = true; let hasChildren = true;
if (typeof children === 'function') { if (typeof remainingProps.children === 'function') {
[children, hasChildren] = children(filterLowered); [children, hasChildren] = remainingProps.children(filterLowered);
} else {
children = remainingProps.children as JSX.Element[];
} }
children = React.Children.map(children, (child) => { children = React.Children.map(children, (child) => {
return React.cloneElement(child, {filterLowered}); return React.cloneElement(child, {filterLowered});
@@ -45,22 +79,28 @@ const BackstageList = ({searchPlaceholder = localizeMessage('backstage_list.sear
if (children.length === 0 || !hasChildren) { if (children.length === 0 || !hasChildren) {
if (!filterLowered) { if (!filterLowered) {
if (remainingProps.emptyText) { if (remainingProps.emptyText) {
children = ( children = [(
<div className='backstage-list__item backstage-list__empty'> <div
className='backstage-list__item backstage-list__empty'
key='emptyText'
>
{remainingProps.emptyText} {remainingProps.emptyText}
</div> </div>
); )];
} }
} else if (remainingProps.emptyTextSearch) { } else if (remainingProps.emptyTextSearch) {
children = ( children = [(
<div <div
className='backstage-list__item backstage-list__empty' className='backstage-list__item backstage-list__empty'
id='emptySearchResultsMessage' id='emptySearchResultsMessage'
key='emptyTextSearch'
> >
{React.cloneElement(remainingProps.emptyTextSearch, {values: {searchTerm: filterLowered}})} {React.cloneElement(remainingProps.emptyTextSearch, {values: {searchTerm: filterLowered}})}
</div> </div>
); )];
} }
} else {
childCount = children.length;
} }
} }
@@ -85,6 +125,19 @@ const BackstageList = ({searchPlaceholder = localizeMessage('backstage_list.sear
); );
} }
const hasFilter = filter.length > 0;
const {startCount, endCount, total, isFirstPage, isLastPage} = getPaging(remainingProps, childCount, hasFilter);
const childrenToDisplay = childCount > 0 ? children.slice(startCount - 1, endCount) : children;
let previousPageFn = remainingProps.previousPage;
let nextPageFn = remainingProps.nextPage;
if (isFirstPage) {
previousPageFn = () => {};
}
if (isLastPage) {
nextPageFn = () => {};
}
return ( return (
<div className='backstage-content'> <div className='backstage-content'>
<div className='backstage-header'> <div className='backstage-header'>
@@ -102,7 +155,6 @@ const BackstageList = ({searchPlaceholder = localizeMessage('backstage_list.sear
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={filter} value={filter}
onChange={updateFilter} onChange={updateFilter}
style={style.search}
id='searchInput' id='searchInput'
/> />
</div> </div>
@@ -111,14 +163,39 @@ const BackstageList = ({searchPlaceholder = localizeMessage('backstage_list.sear
{remainingProps.helpText} {remainingProps.helpText}
</span> </span>
<div className='backstage-list'> <div className='backstage-list'>
{children} {childrenToDisplay}
</div>
<div className='backstage-footer'>
<div className='backstage-footer__cell'>
<FormattedMessage
id='backstage_list.paginatorCount'
defaultMessage='{startCount, number} - {endCount, number} of {total, number}'
values={{
startCount,
endCount,
total,
}}
/>
<button
type='button'
className={'btn btn-quaternary btn-icon btn-sm ml-2 prev ' + (isFirstPage ? 'disabled' : '')}
onClick={previousPageFn}
aria-label={formatMessage({id: 'backstage_list.previousButton.ariaLabel', defaultMessage: 'Previous'})}
>
<PreviousIcon/>
</button>
<button
type='button'
className={'btn btn-quaternary btn-icon btn-sm next ' + (isLastPage ? 'disabled' : '')}
onClick={nextPageFn}
aria-label={formatMessage({id: 'backstage_list.nextButton.ariaLabel', defaultMessage: 'Next'})}
>
<NextIcon/>
</button>
</div>
</div> </div>
</div> </div>
); );
}; };
const style = {
search: {flexGrow: 0, flexShrink: 0},
};
export default BackstageList; export default BackstageList;

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

@@ -192,12 +192,12 @@ export default class Bots extends React.PureComponent<Props, State> {
); );
}; };
bots = (filter?: string): Array<boolean | JSX.Element> => { bots = (filter?: string): [JSX.Element[], boolean] => {
const bots = Object.values(this.props.bots).sort((a, b) => a.username.localeCompare(b.username)); const bots = Object.values(this.props.bots).sort((a, b) => a.username.localeCompare(b.username));
const match = (bot: BotType) => matchesFilter(bot, filter, this.props.owners[bot.user_id]); const match = (bot: BotType) => matchesFilter(bot, filter, this.props.owners[bot.user_id]);
const enabledBots = bots.filter((bot) => bot.delete_at === 0).filter(match).map(this.botToJSX); const enabledBots = bots.filter((bot) => bot.delete_at === 0).filter(match).map(this.botToJSX);
const disabledBots = bots.filter((bot) => bot.delete_at > 0).filter(match).map(this.botToJSX); const disabledBots = bots.filter((bot) => bot.delete_at > 0).filter(match).map(this.botToJSX);
const sections = ( const sections = [(
<div key='sections'> <div key='sections'>
<this.EnabledSection <this.EnabledSection
enabledBots={enabledBots} enabledBots={enabledBots}
@@ -207,7 +207,7 @@ export default class Bots extends React.PureComponent<Props, State> {
disabledBots={disabledBots} disabledBots={disabledBots}
/> />
</div> </div>
); )];
return [sections, enabledBots.length > 0 || disabledBots.length > 0]; return [sections, enabledBots.length > 0 || disabledBots.length > 0];
}; };

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

@@ -11,7 +11,7 @@ import {removeIncomingHook} from 'mattermost-redux/actions/integrations';
import {Permissions} from 'mattermost-redux/constants'; import {Permissions} from 'mattermost-redux/constants';
import {getAllChannels} from 'mattermost-redux/selectors/entities/channels'; import {getAllChannels} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getIncomingHooks} from 'mattermost-redux/selectors/entities/integrations'; import {getFilteredIncomingHooks, getIncomingHooksTotalCount} from 'mattermost-redux/selectors/entities/integrations';
import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles'; import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getUsers} from 'mattermost-redux/selectors/entities/users'; import {getUsers} from 'mattermost-redux/selectors/entities/users';
@@ -21,17 +21,16 @@ import {loadIncomingHooksAndProfilesForTeam} from 'actions/integration_actions';
import InstalledIncomingWebhooks from './installed_incoming_webhooks'; import InstalledIncomingWebhooks from './installed_incoming_webhooks';
function mapStateToProps(state: GlobalState) { function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const teamId = getCurrentTeamId(state); const teamId = getCurrentTeamId(state);
const incomingHooks = getFilteredIncomingHooks(state);
const incomingHooksTotalCount = getIncomingHooksTotalCount(state);
const config = getConfig(state);
const canManageOthersWebhooks = haveITeamPermission(state, teamId, Permissions.MANAGE_OTHERS_INCOMING_WEBHOOKS); const canManageOthersWebhooks = haveITeamPermission(state, teamId, Permissions.MANAGE_OTHERS_INCOMING_WEBHOOKS);
const incomingHooks = getIncomingHooks(state);
const incomingWebhooks = Object.keys(incomingHooks).
map((key) => incomingHooks[key]).
filter((incomingWebhook) => incomingWebhook.team_id === teamId);
const enableIncomingWebhooks = config.EnableIncomingWebhooks === 'true'; const enableIncomingWebhooks = config.EnableIncomingWebhooks === 'true';
return { return {
incomingWebhooks, incomingHooks,
incomingHooksTotalCount,
channels: getAllChannels(state), channels: getAllChannels(state),
users: getUsers(state), users: getUsers(state),
canManageOthersWebhooks, canManageOthersWebhooks,

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

@@ -5,7 +5,7 @@ import React from 'react';
import {FormattedMessage} from 'react-intl'; import {FormattedMessage} from 'react-intl';
import type {Channel} from '@mattermost/types/channels'; import type {Channel} from '@mattermost/types/channels';
import type {IncomingWebhook} from '@mattermost/types/integrations'; import type {IncomingWebhook, IncomingWebhooksWithCount} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams'; import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users'; import type {UserProfile} from '@mattermost/types/users';
import type {IDMappedObjects} from '@mattermost/types/utilities'; import type {IDMappedObjects} from '@mattermost/types/utilities';
@@ -17,25 +17,29 @@ import ExternalLink from 'components/external_link';
import FormattedMarkdownMessage from 'components/formatted_markdown_message'; import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import InstalledIncomingWebhook, {matchesFilter} from 'components/integrations/installed_incoming_webhook'; import InstalledIncomingWebhook, {matchesFilter} from 'components/integrations/installed_incoming_webhook';
import {Constants, DeveloperLinks} from 'utils/constants'; import {DeveloperLinks} from 'utils/constants';
import * as Utils from 'utils/utils'; import * as Utils from 'utils/utils';
const PAGE_SIZE = 200;
type Props = { type Props = {
team: Team; team: Team;
user: UserProfile; user: UserProfile;
canManageOthersWebhooks: boolean; incomingHooks: IncomingWebhook[];
incomingWebhooks: IncomingWebhook[]; incomingHooksTotalCount: number;
channels: IDMappedObjects<Channel>; channels: IDMappedObjects<Channel>;
users: IDMappedObjects<UserProfile>; users: IDMappedObjects<UserProfile>;
canManageOthersWebhooks: boolean;
enableIncomingWebhooks: boolean; enableIncomingWebhooks: boolean;
actions: { actions: {
removeIncomingHook: (hookId: string) => Promise<ActionResult>; removeIncomingHook: (hookId: string) => Promise<ActionResult>;
loadIncomingHooksAndProfilesForTeam: (teamId: string, startPageNumber: number, loadIncomingHooksAndProfilesForTeam: (teamId: string, startPageNumber: number,
pageSize: number) => Promise<ActionResult>; pageSize: number, includeTotalCount: boolean) => Promise<ActionResult<IncomingWebhook[] | IncomingWebhooksWithCount>>;
}; };
} }
type State = { type State = {
page: number;
loading: boolean; loading: boolean;
} }
@@ -44,26 +48,43 @@ export default class InstalledIncomingWebhooks extends React.PureComponent<Props
super(props); super(props);
this.state = { this.state = {
page: 0,
loading: true, loading: true,
}; };
} }
componentDidMount() { componentDidMount() {
if (this.props.enableIncomingWebhooks) { this.loadPage(0);
this.props.actions.loadIncomingHooksAndProfilesForTeam(
this.props.team.id,
Constants.Integrations.START_PAGE_NUM,
Constants.Integrations.PAGE_SIZE,
).then(
() => this.setState({loading: false}),
);
}
} }
deleteIncomingWebhook = (incomingWebhook: IncomingWebhook) => { deleteIncomingWebhook = (incomingWebhook: IncomingWebhook) => {
this.props.actions.removeIncomingHook(incomingWebhook.id); this.props.actions.removeIncomingHook(incomingWebhook.id);
}; };
loadPage = async (pageToLoad: number) => {
if (this.props.enableIncomingWebhooks) {
this.setState({loading: true},
async () => {
await this.props.actions.loadIncomingHooksAndProfilesForTeam(
this.props.team.id,
pageToLoad,
PAGE_SIZE,
true,
);
this.setState({page: pageToLoad, loading: false});
},
);
}
};
nextPage = () => {
this.loadPage(this.state.page + 1);
};
previousPage = () => {
this.loadPage(this.state.page - 1);
};
incomingWebhookCompare = (a: IncomingWebhook, b: IncomingWebhook) => { incomingWebhookCompare = (a: IncomingWebhook, b: IncomingWebhook) => {
let displayNameA = a.display_name; let displayNameA = a.display_name;
if (!displayNameA) { if (!displayNameA) {
@@ -76,11 +97,10 @@ export default class InstalledIncomingWebhooks extends React.PureComponent<Props
} }
const displayNameB = b.display_name; const displayNameB = b.display_name;
return displayNameA.localeCompare(displayNameB); return displayNameA.localeCompare(displayNameB);
}; };
incomingWebhooks = (filter: string) => this.props.incomingWebhooks. incomingWebhooks = (filter: string) => this.props.incomingHooks.
sort(this.incomingWebhookCompare). sort(this.incomingWebhookCompare).
filter((incomingWebhook: IncomingWebhook) => matchesFilter(incomingWebhook, this.props.channels[incomingWebhook.channel_id], filter)). filter((incomingWebhook: IncomingWebhook) => matchesFilter(incomingWebhook, this.props.channels[incomingWebhook.channel_id], filter)).
map((incomingWebhook: IncomingWebhook) => { map((incomingWebhook: IncomingWebhook) => {
@@ -160,6 +180,11 @@ export default class InstalledIncomingWebhooks extends React.PureComponent<Props
} }
searchPlaceholder={Utils.localizeMessage('installed_incoming_webhooks.search', 'Search Incoming Webhooks')} searchPlaceholder={Utils.localizeMessage('installed_incoming_webhooks.search', 'Search Incoming Webhooks')}
loading={this.state.loading} loading={this.state.loading}
nextPage={this.nextPage}
previousPage={this.previousPage}
page={this.state.page}
pageSize={PAGE_SIZE}
total={this.props.incomingHooksTotalCount}
> >
{(filter: string) => { {(filter: string) => {
const children = this.incomingWebhooks(filter); const children = this.incomingWebhooks(filter);

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

@@ -161,12 +161,6 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
id="searchInput" id="searchInput"
onChange={[Function]} onChange={[Function]}
placeholder="Search Outgoing OAuth Connections" placeholder="Search Outgoing OAuth Connections"
style={
Object {
"flexGrow": 0,
"flexShrink": 0,
}
}
type="search" type="search"
value="" value=""
/> />
@@ -223,7 +217,9 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
<div <div
className="backstage-list" className="backstage-list"
> >
<LoadingScreen> <LoadingScreen
key="loading"
>
<div <div
className="loading-screen" className="loading-screen"
style={ style={
@@ -251,6 +247,55 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
</div> </div>
</LoadingScreen> </LoadingScreen>
</div> </div>
<div
className="backstage-footer"
>
<div
className="backstage-footer__cell"
>
<FormattedMessage
defaultMessage="{startCount, number} - {endCount, number} of {total, number}"
id="backstage_list.paginatorCount"
values={
Object {
"endCount": 0,
"startCount": 0,
"total": 0,
}
}
>
<span>
0 - 0 of 0
</span>
</FormattedMessage>
<button
aria-label="Previous"
className="btn btn-quaternary btn-icon btn-sm ml-2 prev disabled"
onClick={[Function]}
type="button"
>
<Memo(PreviousIcon)>
<i
className="icon icon-chevron-left"
title="Previous Icon"
/>
</Memo(PreviousIcon)>
</button>
<button
aria-label="Next"
className="btn btn-quaternary btn-icon btn-sm next disabled"
onClick={[Function]}
type="button"
>
<Memo(NextIcon)>
<i
className="icon icon-chevron-right"
title="Next Icon"
/>
</Memo(NextIcon)>
</button>
</div>
</div>
</div> </div>
</BackstageList> </BackstageList>
</InstalledOutgoingOAuthConnections> </InstalledOutgoingOAuthConnections>

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

@@ -2961,6 +2961,9 @@
"avatar.alt": "{username} profile image", "avatar.alt": "{username} profile image",
"avatars.overflowUnnamedOnly": "{overflowUnnamedCount, plural, =1 {one other} other {# others}}", "avatars.overflowUnnamedOnly": "{overflowUnnamedCount, plural, =1 {one other} other {# others}}",
"avatars.overflowUsers": "{overflowUnnamedCount, plural, =0 {{names}} =1 {{names} and one other} other {{names} and # others}}", "avatars.overflowUsers": "{overflowUnnamedCount, plural, =0 {{names}} =1 {{names} and one other} other {{names} and # others}}",
"backstage_list.nextButton.ariaLabel": "Next",
"backstage_list.paginatorCount": "{startCount, number} - {endCount, number} of {total, number}",
"backstage_list.previousButton.ariaLabel": "Previous",
"backstage_list.search": "Search", "backstage_list.search": "Search",
"backstage_navbar.back": "Back", "backstage_navbar.back": "Back",
"backstage_navbar.backToMattermost": "Back to {siteName}", "backstage_navbar.backToMattermost": "Back to {siteName}",

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

@@ -7,6 +7,7 @@ export default keyMirror({
RECEIVED_INCOMING_HOOK: null, RECEIVED_INCOMING_HOOK: null,
RECEIVED_INCOMING_HOOKS: null, RECEIVED_INCOMING_HOOKS: null,
RECEIVED_INCOMING_HOOKS_TOTAL_COUNT: null,
DELETED_INCOMING_HOOK: null, DELETED_INCOMING_HOOK: null,
RECEIVED_OUTGOING_HOOK: null, RECEIVED_OUTGOING_HOOK: null,
RECEIVED_OUTGOING_HOOKS: null, RECEIVED_OUTGOING_HOOKS: null,

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

@@ -86,17 +86,31 @@ describe('Actions.Integrations', () => {
} as IncomingWebhook, } as IncomingWebhook,
)); ));
/* Test with include_total_count being set to false */
nock(Client4.getBaseRoute()). nock(Client4.getBaseRoute()).
get('/hooks/incoming'). get('/hooks/incoming').
query(true). query(true).
reply(200, [created]); reply(200, [created]);
await store.dispatch(Actions.getIncomingHooks(TestHelper.basicTeam!.id)); const response = await store.dispatch(Actions.getIncomingHooks(TestHelper.basicTeam!.id));
const state = store.getState(); expect(response.data).toBeTruthy();
expect(response.data[0].id === created.id).toBeTruthy();
const state = store.getState();
const hooks = state.entities.integrations.incomingHooks; const hooks = state.entities.integrations.incomingHooks;
expect(hooks).toBeTruthy(); expect(hooks).toBeTruthy();
expect(hooks[created.id]).toBeTruthy(); expect(hooks[created.id]).toBeTruthy();
/* Test with include_total_count being set to true */
nock(Client4.getBaseRoute()).
get('/hooks/incoming').
query(true).
reply(200, {incoming_webhooks: [created], total_count: 1});
const responseWithCount = await store.dispatch(Actions.getIncomingHooks(TestHelper.basicTeam!.id, 0, 10, true));
expect(responseWithCount.data.incoming_webhooks).toBeTruthy();
expect(responseWithCount.data.incoming_webhooks[0].id === created.id).toBeTruthy();
expect(responseWithCount.data.total_count === 1).toBeTruthy();
}); });
it('removeIncomingHook', async () => { it('removeIncomingHook', async () => {

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

@@ -1,9 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type {AnyAction} from 'redux';
import {batchActions} from 'redux-batched-actions'; import {batchActions} from 'redux-batched-actions';
import type {Command, CommandArgs, DialogSubmission, IncomingWebhook, OAuthApp, OutgoingOAuthConnection, OutgoingWebhook, SubmitDialogResponse} from '@mattermost/types/integrations'; import type {Command, CommandArgs, DialogSubmission, IncomingWebhook, IncomingWebhooksWithCount, OAuthApp, OutgoingOAuthConnection, OutgoingWebhook, SubmitDialogResponse} from '@mattermost/types/integrations';
import {IntegrationTypes} from 'mattermost-redux/action_types'; import {IntegrationTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client'; import {Client4} from 'mattermost-redux/client';
@@ -37,16 +38,41 @@ export function getIncomingHook(hookId: string) {
}); });
} }
export function getIncomingHooks(teamId = '', page = 0, perPage: number = General.PAGE_SIZE_DEFAULT) { export function getIncomingHooks(teamId = '', page = 0, perPage: number = General.PAGE_SIZE_DEFAULT, includeTotalCount = false): ActionFuncAsync<IncomingWebhook[] | IncomingWebhooksWithCount> {
return bindClientFunc({ return async (dispatch, getState) => {
clientFunc: Client4.getIncomingWebhooks, let data;
onSuccess: [IntegrationTypes.RECEIVED_INCOMING_HOOKS],
params: [ try {
teamId, data = await Client4.getIncomingWebhooks(teamId, page, perPage, includeTotalCount);
page, } catch (error) {
perPage, forceLogoutIfNecessary(error, dispatch, getState);
], dispatch(logError(error));
}); return {error};
}
const isWebhooksWithCount = isIncomingWebhooksWithCount(data);
const actions: AnyAction[] = [{
type: IntegrationTypes.RECEIVED_INCOMING_HOOKS,
data: isWebhooksWithCount ? (data as IncomingWebhooksWithCount).incoming_webhooks : data,
}];
if (isWebhooksWithCount) {
actions.push({
type: IntegrationTypes.RECEIVED_INCOMING_HOOKS_TOTAL_COUNT,
data: (data as IncomingWebhooksWithCount).total_count,
});
}
dispatch(batchActions(actions));
return {data};
};
}
export function isIncomingWebhooksWithCount(data: any): data is IncomingWebhooksWithCount {
return typeof data.incoming_webhooks !== 'undefined' &&
Array.isArray(data.incoming_webhooks) &&
typeof data.total_count === 'number';
} }
export function removeIncomingHook(hookId: string): ActionFuncAsync { export function removeIncomingHook(hookId: string): ActionFuncAsync {

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

@@ -52,6 +52,19 @@ function incomingHooks(state: IDMappedObjects<IncomingWebhook> = {}, action: Any
} }
} }
function incomingHooksTotalCount(state: number = 0, action: AnyAction) {
switch (action.type) {
case IntegrationTypes.RECEIVED_INCOMING_HOOKS_TOTAL_COUNT: {
return action.data;
}
case IntegrationTypes.DELETED_INCOMING_HOOK: {
return Math.max(state - 1, 0);
}
default:
return state;
}
}
function outgoingHooks(state: IDMappedObjects<OutgoingWebhook> = {}, action: AnyAction) { function outgoingHooks(state: IDMappedObjects<OutgoingWebhook> = {}, action: AnyAction) {
switch (action.type) { switch (action.type) {
case IntegrationTypes.RECEIVED_OUTGOING_HOOK: { case IntegrationTypes.RECEIVED_OUTGOING_HOOK: {
@@ -306,6 +319,9 @@ export default combineReducers({
// object where every key is the hook id and has an object with the incoming hook details // object where every key is the hook id and has an object with the incoming hook details
incomingHooks, incomingHooks,
// object to represent total amount of incoming hooks
incomingHooksTotalCount,
// object where every key is the hook id and has an object with the outgoing hook details // object where every key is the hook id and has an object with the outgoing hook details
outgoingHooks, outgoingHooks,

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

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type {OutgoingWebhook, Command} from '@mattermost/types/integrations'; import type {IncomingWebhook, OutgoingWebhook, Command} from '@mattermost/types/integrations';
import type {GlobalState} from '@mattermost/types/store'; import type {GlobalState} from '@mattermost/types/store';
import type {IDMappedObjects} from '@mattermost/types/utilities'; import type {IDMappedObjects} from '@mattermost/types/utilities';
@@ -14,6 +14,10 @@ export function getIncomingHooks(state: GlobalState) {
return state.entities.integrations.incomingHooks; return state.entities.integrations.incomingHooks;
} }
export function getIncomingHooksTotalCount(state: GlobalState) {
return state.entities.integrations.incomingHooksTotalCount;
}
export function getOutgoingHooks(state: GlobalState) { export function getOutgoingHooks(state: GlobalState) {
return state.entities.integrations.outgoingHooks; return state.entities.integrations.outgoingHooks;
} }
@@ -30,6 +34,17 @@ export function getOutgoingOAuthConnections(state: GlobalState) {
return state.entities.integrations.outgoingOAuthConnections; return state.entities.integrations.outgoingOAuthConnections;
} }
export const getFilteredIncomingHooks: (state: GlobalState) => IncomingWebhook[] = createSelector(
'getFilteredIncomingHooks',
getCurrentTeamId,
getIncomingHooks,
(teamId, hooks) => {
return Object.keys(hooks).
map((key) => hooks[key]).
filter((incomingHook) => incomingHook.team_id === teamId);
},
);
export const getAppsOAuthAppIDs: (state: GlobalState) => string[] = createSelector( export const getAppsOAuthAppIDs: (state: GlobalState) => string[] = createSelector(
'getAppsOAuthAppIDs', 'getAppsOAuthAppIDs',
appsEnabled, appsEnabled,

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

@@ -125,6 +125,7 @@ const state: GlobalState = {
}, },
integrations: { integrations: {
incomingHooks: {}, incomingHooks: {},
incomingHooksTotalCount: 0,
outgoingHooks: {}, outgoingHooks: {},
oauthApps: {}, oauthApps: {},
systemCommands: {}, systemCommands: {},

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

@@ -87,6 +87,7 @@ import type {
CommandResponse, CommandResponse,
DialogSubmission, DialogSubmission,
IncomingWebhook, IncomingWebhook,
IncomingWebhooksWithCount,
OAuthApp, OAuthApp,
OutgoingOAuthConnection, OutgoingOAuthConnection,
OutgoingWebhook, OutgoingWebhook,
@@ -2625,17 +2626,18 @@ export default class Client4 {
); );
}; };
getIncomingWebhooks = (teamId = '', page = 0, perPage = PER_PAGE_DEFAULT) => { getIncomingWebhooks = (teamId = '', page = 0, perPage = PER_PAGE_DEFAULT, includeTotalCount = false) => {
const queryParams: any = { const queryParams: any = {
page, page,
per_page: perPage, per_page: perPage,
include_total_count: includeTotalCount,
}; };
if (teamId) { if (teamId) {
queryParams.team_id = teamId; queryParams.team_id = teamId;
} }
return this.doFetch<IncomingWebhook[]>( return this.doFetch<IncomingWebhook[] | IncomingWebhooksWithCount>(
`${this.getIncomingHooksRoute()}${buildQueryString(queryParams)}`, `${this.getIncomingHooksRoute()}${buildQueryString(queryParams)}`,
{method: 'get'}, {method: 'get'},
); );

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

@@ -19,6 +19,11 @@ export type IncomingWebhook = {
channel_locked: boolean; channel_locked: boolean;
}; };
export type IncomingWebhooksWithCount = {
incoming_webhooks: IncomingWebhook[];
total_count: number;
};
export type OutgoingWebhook = { export type OutgoingWebhook = {
id: string; id: string;
token: string; token: string;
@@ -121,6 +126,7 @@ export type OutgoingOAuthConnection = {
export type IntegrationsState = { export type IntegrationsState = {
incomingHooks: IDMappedObjects<IncomingWebhook>; incomingHooks: IDMappedObjects<IncomingWebhook>;
incomingHooksTotalCount: number;
outgoingHooks: IDMappedObjects<OutgoingWebhook>; outgoingHooks: IDMappedObjects<OutgoingWebhook>;
oauthApps: IDMappedObjects<OAuthApp>; oauthApps: IDMappedObjects<OAuthApp>;
outgoingOAuthConnections: IDMappedObjects<OutgoingOAuthConnection>; outgoingOAuthConnections: IDMappedObjects<OutgoingOAuthConnection>;