diff --git a/api/v4/source/webhooks.yaml b/api/v4/source/webhooks.yaml index f4853f9264..f60b67da1f 100644 --- a/api/v4/source/webhooks.yaml +++ b/api/v4/source/webhooks.yaml @@ -83,6 +83,13 @@ description: The ID of the team to get hooks for. schema: 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: "200": description: Incoming webhooks retrieval successful diff --git a/server/channels/api4/webhook.go b/server/channels/api4/webhook.go index 8f3c47b4d8..245fda14e2 100644 --- a/server/channels/api4/webhook.go +++ b/server/channels/api4/webhook.go @@ -184,6 +184,8 @@ func getIncomingHooks(c *Context, w http.ResponseWriter, r *http.Request) { hooks []*model.IncomingWebhook appErr *model.AppError + js []byte + err error ) if teamID != "" { @@ -217,7 +219,20 @@ func getIncomingHooks(c *Context, w http.ResponseWriter, r *http.Request) { 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 { c.Err = model.NewAppError("getIncomingHooks", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) return diff --git a/server/channels/api4/webhook_test.go b/server/channels/api4/webhook_test.go index f862630597..4c6f612243 100644 --- a/server/channels/api4/webhook_test.go +++ b/server/channels/api4/webhook_test.go @@ -304,6 +304,60 @@ func TestGetIncomingWebhooksByTeam(t *testing.T) { 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) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/server/channels/app/analytics.go b/server/channels/app/analytics.go index b87fe951b5..57de39d6de 100644 --- a/server/channels/app/analytics.go +++ b/server/channels/app/analytics.go @@ -241,7 +241,7 @@ func (a *App) getAnalytics(rctx request.CTX, name string, teamID string, forSupp var incomingWebhookCount int64 g2.Go(func() 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 nil diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 6f10ab37c1..4232865199 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -717,6 +717,7 @@ type AppIface interface { GetGroupsByUserId(userID string) ([]*model.Group, *model.AppError) GetHubForUserId(userID string) *platform.Hub 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) GetIncomingWebhooksForTeamPageByUser(teamID string, userID string, page, perPage int) ([]*model.IncomingWebhook, *model.AppError) GetIncomingWebhooksPage(page, perPage int) ([]*model.IncomingWebhook, *model.AppError) diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index a6057ee3f7..5e3dcdc552 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -7220,6 +7220,28 @@ func (a *OpenTracingAppLayer) GetIncomingWebhook(hookID string) (*model.Incoming 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) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetIncomingWebhooksForTeamPage") diff --git a/server/channels/app/webhook.go b/server/channels/app/webhook.go index c7768ed6a7..2cbf585475 100644 --- a/server/channels/app/webhook.go +++ b/server/channels/app/webhook.go @@ -507,6 +507,19 @@ func (a *App) GetIncomingWebhooksPage(page, perPage int) ([]*model.IncomingWebho 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) { if !*a.Config().ServiceSettings.EnableOutgoingWebhooks { return nil, model.NewAppError("CreateOutgoingWebhook", "api.outgoing_webhook.disabled.app_error", nil, "", http.StatusNotImplemented) diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 6df7e1d4a9..81dbca13ab 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -12908,7 +12908,7 @@ func (s *OpenTracingLayerUserTermsOfServiceStore) Save(userTermsOfService *model 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() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "WebhookStore.AnalyticsIncomingCount") s.Root.Store.SetContext(newCtx) @@ -12917,7 +12917,7 @@ func (s *OpenTracingLayerWebhookStore) AnalyticsIncomingCount(teamID string) (in }() defer span.Finish() - result, err := s.WebhookStore.AnalyticsIncomingCount(teamID) + result, err := s.WebhookStore.AnalyticsIncomingCount(teamID, userID) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index cfbb2ccb55..c7dea7a757 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -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 for { - result, err := s.WebhookStore.AnalyticsIncomingCount(teamID) + result, err := s.WebhookStore.AnalyticsIncomingCount(teamID, userID) if err == nil { return result, nil } diff --git a/server/channels/store/sqlstore/webhook_store.go b/server/channels/store/sqlstore/webhook_store.go index deb3a36eb3..fff5b147f9 100644 --- a/server/channels/store/sqlstore/webhook_store.go +++ b/server/channels/store/sqlstore/webhook_store.go @@ -352,15 +352,19 @@ func (s SqlWebhookStore) UpdateOutgoing(hook *model.OutgoingWebhook) (*model.Out return hook, nil } -func (s SqlWebhookStore) AnalyticsIncomingCount(teamId string) (int64, error) { +func (s SqlWebhookStore) AnalyticsIncomingCount(teamID string, userID string) (int64, error) { queryBuilder := s.getQueryBuilder(). Select("COUNT(*)"). From("IncomingWebhooks"). Where("DeleteAt = 0") - if teamId != "" { - queryBuilder = queryBuilder.Where("TeamId", teamId) + if teamID != "" { + queryBuilder = queryBuilder.Where(sq.Eq{"TeamId": teamID}) + } + + if userID != "" { + queryBuilder = queryBuilder.Where(sq.Eq{"UserId": userID}) } queryString, args, err := queryBuilder.ToSql() diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 2a2910826c..cbcb8325f9 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -615,7 +615,7 @@ type WebhookStore interface { PermanentDeleteOutgoingByUser(userID string) 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) InvalidateWebhookCache(webhook string) ClearCaches() diff --git a/server/channels/store/storetest/mocks/WebhookStore.go b/server/channels/store/storetest/mocks/WebhookStore.go index 9831e88ead..b91afdcd75 100644 --- a/server/channels/store/storetest/mocks/WebhookStore.go +++ b/server/channels/store/storetest/mocks/WebhookStore.go @@ -14,9 +14,9 @@ type WebhookStore struct { mock.Mock } -// AnalyticsIncomingCount provides a mock function with given fields: teamID -func (_m *WebhookStore) AnalyticsIncomingCount(teamID string) (int64, error) { - ret := _m.Called(teamID) +// AnalyticsIncomingCount provides a mock function with given fields: teamID, userID +func (_m *WebhookStore) AnalyticsIncomingCount(teamID string, userID string) (int64, error) { + ret := _m.Called(teamID, userID) if len(ret) == 0 { panic("no return value specified for AnalyticsIncomingCount") @@ -24,17 +24,17 @@ func (_m *WebhookStore) AnalyticsIncomingCount(teamID string) (int64, error) { var r0 int64 var r1 error - if rf, ok := ret.Get(0).(func(string) (int64, error)); ok { - return rf(teamID) + if rf, ok := ret.Get(0).(func(string, string) (int64, error)); ok { + return rf(teamID, userID) } - if rf, ok := ret.Get(0).(func(string) int64); ok { - r0 = rf(teamID) + if rf, ok := ret.Get(0).(func(string, string) int64); ok { + r0 = rf(teamID, userID) } else { r0 = ret.Get(0).(int64) } - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(teamID) + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(teamID, userID) } else { r1 = ret.Error(1) } diff --git a/server/channels/store/storetest/webhook_store.go b/server/channels/store/storetest/webhook_store.go index 3cc18098e1..6de9ddbb1a 100644 --- a/server/channels/store/storetest/webhook_store.go +++ b/server/channels/store/storetest/webhook_store.go @@ -577,10 +577,28 @@ func testWebhookStoreCountIncoming(t *testing.T, rctx request.CTX, ss store.Stor _, _ = ss.Webhook().SaveIncoming(o1) - c, err := ss.Webhook().AnalyticsIncomingCount("") + c, err := ss.Webhook().AnalyticsIncomingCount("", "") require.NoError(t, err) - 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) { diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 6e4cd98b34..3fbef4d0f3 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -11618,10 +11618,10 @@ func (s *TimerLayerUserTermsOfServiceStore) Save(userTermsOfService *model.UserT return result, err } -func (s *TimerLayerWebhookStore) AnalyticsIncomingCount(teamID string) (int64, error) { +func (s *TimerLayerWebhookStore) AnalyticsIncomingCount(teamID string, userID string) (int64, error) { start := time.Now() - result, err := s.WebhookStore.AnalyticsIncomingCount(teamID) + result, err := s.WebhookStore.AnalyticsIncomingCount(teamID, userID) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { diff --git a/server/i18n/en.json b/server/i18n/en.json index 2fb797722e..57d52402b7 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -7046,6 +7046,10 @@ "id": "app.webhooks.get_incoming_by_user.app_error", "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", "translation": "Unable to get the webhook." diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index 06763763d0..083b1f34b0 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -351,7 +351,7 @@ func (ts *TelemetryService) trackActivity() { 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 } diff --git a/server/platform/services/telemetry/telemetry_test.go b/server/platform/services/telemetry/telemetry_test.go index 4f6b14e3f9..40bd655663 100644 --- a/server/platform/services/telemetry/telemetry_test.go +++ b/server/platform/services/telemetry/telemetry_test.go @@ -231,7 +231,7 @@ func initializeMocks(cfg *model.Config, cloudLicense bool) (*mocks.ServerIface, commandStore.On("AnalyticsCommandCount", "").Return(int64(15), nil) webhookStore := storeMocks.WebhookStore{} - webhookStore.On("AnalyticsIncomingCount", "").Return(int64(16), nil) + webhookStore.On("AnalyticsIncomingCount", "", "").Return(int64(16), nil) webhookStore.On("AnalyticsOutgoingCount", "").Return(int64(17), nil) groupStore := storeMocks.GroupStore{} diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 896b5747d7..077a4ea4d3 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -4983,6 +4983,24 @@ func (c *Client4) GetIncomingWebhooks(ctx context.Context, page int, perPage int 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. 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) diff --git a/server/public/model/incoming_webhook.go b/server/public/model/incoming_webhook.go index a84a13d41d..81b035434e 100644 --- a/server/public/model/incoming_webhook.go +++ b/server/public/model/incoming_webhook.go @@ -59,6 +59,11 @@ type IncomingWebhookRequest struct { Priority *PostPriority `json:"priority"` } +type IncomingWebhooksWithCount struct { + Webhooks []*IncomingWebhook `json:"incoming_webhooks"` + TotalCount int64 `json:"total_count"` +} + func (o *IncomingWebhook) IsValid() *AppError { if !IsValidId(o.Id) { return NewAppError("IncomingWebhook.IsValid", "model.incoming_hook.id.app_error", nil, "", http.StatusBadRequest) diff --git a/webapp/channels/src/actions/integration_actions.tsx b/webapp/channels/src/actions/integration_actions.tsx index 5185b729e9..0046d94a02 100644 --- a/webapp/channels/src/actions/integration_actions.tsx +++ b/webapp/channels/src/actions/integration_actions.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // 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 {getProfilesByIds} from 'mattermost-redux/actions/users'; @@ -11,11 +11,13 @@ import type {ActionFuncAsync} from 'mattermost-redux/types/actions'; const DEFAULT_PAGE_SIZE = 100; -export function loadIncomingHooksAndProfilesForTeam(teamId: string, page = 0, perPage = DEFAULT_PAGE_SIZE): ActionFuncAsync { +export function loadIncomingHooksAndProfilesForTeam(teamId: string, page = 0, perPage = DEFAULT_PAGE_SIZE, includeTotalCount = false): ActionFuncAsync { return async (dispatch) => { - const {data} = await dispatch(IntegrationActions.getIncomingHooks(teamId, page, perPage)); + const {data} = await dispatch(IntegrationActions.getIncomingHooks(teamId, page, perPage, includeTotalCount)); 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}; }; diff --git a/webapp/channels/src/components/backstage/components/backstage_list.scss b/webapp/channels/src/components/backstage/components/backstage_list.scss new file mode 100644 index 0000000000..769812befb --- /dev/null +++ b/webapp/channels/src/components/backstage/components/backstage_list.scss @@ -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; + } +} diff --git a/webapp/channels/src/components/backstage/components/backstage_list.tsx b/webapp/channels/src/components/backstage/components/backstage_list.tsx index b6280f7670..b0d1b55ab8 100644 --- a/webapp/channels/src/components/backstage/components/backstage_list.tsx +++ b/webapp/channels/src/components/backstage/components/backstage_list.tsx @@ -3,15 +3,20 @@ import React, {useState} from 'react'; import type {ChangeEvent, ReactNode} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; import {Link} from 'react-router-dom'; 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 {localizeMessage} from 'utils/utils'; +import './backstage_list.scss'; + type Props = { - children?: ReactNode | ((filter: string) => void); + children?: JSX.Element[] | ((filter: string) => [JSX.Element[], boolean]); header: ReactNode; addLink?: string; addText?: ReactNode; @@ -21,23 +26,52 @@ type Props = { helpText?: ReactNode; loading: boolean; 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 {formatMessage} = useIntl(); + const [filter, setFilter] = useState(''); - const updateFilter = (e: ChangeEvent) => setFilter(e.target.value); - const filterLowered = filter.toLowerCase(); - let children; + let children = []; + let childCount = 0; if (remainingProps.loading) { - children = ; + children = [ + , + ]; } else { - children = remainingProps.children; let hasChildren = true; - if (typeof children === 'function') { - [children, hasChildren] = children(filterLowered); + if (typeof remainingProps.children === 'function') { + [children, hasChildren] = remainingProps.children(filterLowered); + } else { + children = remainingProps.children as JSX.Element[]; } children = React.Children.map(children, (child) => { return React.cloneElement(child, {filterLowered}); @@ -45,22 +79,28 @@ const BackstageList = ({searchPlaceholder = localizeMessage('backstage_list.sear if (children.length === 0 || !hasChildren) { if (!filterLowered) { if (remainingProps.emptyText) { - children = ( -
+ children = [( +
{remainingProps.emptyText}
- ); + )]; } } else if (remainingProps.emptyTextSearch) { - children = ( + children = [(
{React.cloneElement(remainingProps.emptyTextSearch, {values: {searchTerm: filterLowered}})}
- ); + )]; } + } 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 (
@@ -102,7 +155,6 @@ const BackstageList = ({searchPlaceholder = localizeMessage('backstage_list.sear placeholder={searchPlaceholder} value={filter} onChange={updateFilter} - style={style.search} id='searchInput' />
@@ -111,14 +163,39 @@ const BackstageList = ({searchPlaceholder = localizeMessage('backstage_list.sear {remainingProps.helpText}
- {children} + {childrenToDisplay} +
+
+
+ + + +
); }; -const style = { - search: {flexGrow: 0, flexShrink: 0}, -}; - export default BackstageList; diff --git a/webapp/channels/src/components/integrations/bots/bots.tsx b/webapp/channels/src/components/integrations/bots/bots.tsx index 8f3070520c..6e9c9983f9 100644 --- a/webapp/channels/src/components/integrations/bots/bots.tsx +++ b/webapp/channels/src/components/integrations/bots/bots.tsx @@ -192,12 +192,12 @@ export default class Bots extends React.PureComponent { ); }; - bots = (filter?: string): Array => { + bots = (filter?: string): [JSX.Element[], boolean] => { 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 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 sections = ( + const sections = [(
{ disabledBots={disabledBots} />
- ); + )]; return [sections, enabledBots.length > 0 || disabledBots.length > 0]; }; diff --git a/webapp/channels/src/components/integrations/installed_incoming_webhooks/index.ts b/webapp/channels/src/components/integrations/installed_incoming_webhooks/index.ts index 9bf7832eb8..dfacb54255 100644 --- a/webapp/channels/src/components/integrations/installed_incoming_webhooks/index.ts +++ b/webapp/channels/src/components/integrations/installed_incoming_webhooks/index.ts @@ -11,7 +11,7 @@ import {removeIncomingHook} from 'mattermost-redux/actions/integrations'; import {Permissions} from 'mattermost-redux/constants'; import {getAllChannels} from 'mattermost-redux/selectors/entities/channels'; 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 {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getUsers} from 'mattermost-redux/selectors/entities/users'; @@ -21,17 +21,16 @@ import {loadIncomingHooksAndProfilesForTeam} from 'actions/integration_actions'; import InstalledIncomingWebhooks from './installed_incoming_webhooks'; function mapStateToProps(state: GlobalState) { - const config = getConfig(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 incomingHooks = getIncomingHooks(state); - const incomingWebhooks = Object.keys(incomingHooks). - map((key) => incomingHooks[key]). - filter((incomingWebhook) => incomingWebhook.team_id === teamId); const enableIncomingWebhooks = config.EnableIncomingWebhooks === 'true'; return { - incomingWebhooks, + incomingHooks, + incomingHooksTotalCount, channels: getAllChannels(state), users: getUsers(state), canManageOthersWebhooks, diff --git a/webapp/channels/src/components/integrations/installed_incoming_webhooks/installed_incoming_webhooks.tsx b/webapp/channels/src/components/integrations/installed_incoming_webhooks/installed_incoming_webhooks.tsx index c82c2a3cc8..59c78439b6 100644 --- a/webapp/channels/src/components/integrations/installed_incoming_webhooks/installed_incoming_webhooks.tsx +++ b/webapp/channels/src/components/integrations/installed_incoming_webhooks/installed_incoming_webhooks.tsx @@ -5,7 +5,7 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; 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 {UserProfile} from '@mattermost/types/users'; 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 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'; +const PAGE_SIZE = 200; + type Props = { team: Team; user: UserProfile; - canManageOthersWebhooks: boolean; - incomingWebhooks: IncomingWebhook[]; + incomingHooks: IncomingWebhook[]; + incomingHooksTotalCount: number; channels: IDMappedObjects; users: IDMappedObjects; + canManageOthersWebhooks: boolean; enableIncomingWebhooks: boolean; actions: { removeIncomingHook: (hookId: string) => Promise; loadIncomingHooksAndProfilesForTeam: (teamId: string, startPageNumber: number, - pageSize: number) => Promise; + pageSize: number, includeTotalCount: boolean) => Promise>; }; } type State = { + page: number; loading: boolean; } @@ -44,26 +48,43 @@ export default class InstalledIncomingWebhooks extends React.PureComponent this.setState({loading: false}), - ); - } + this.loadPage(0); } deleteIncomingWebhook = (incomingWebhook: IncomingWebhook) => { 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) => { let displayNameA = a.display_name; if (!displayNameA) { @@ -76,11 +97,10 @@ export default class InstalledIncomingWebhooks extends React.PureComponent this.props.incomingWebhooks. + incomingWebhooks = (filter: string) => this.props.incomingHooks. sort(this.incomingWebhookCompare). filter((incomingWebhook: IncomingWebhook) => matchesFilter(incomingWebhook, this.props.channels[incomingWebhook.channel_id], filter)). map((incomingWebhook: IncomingWebhook) => { @@ -160,6 +180,11 @@ export default class InstalledIncomingWebhooks extends React.PureComponent {(filter: string) => { const children = this.incomingWebhooks(filter); diff --git a/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap b/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap index 689ab2a5ad..70dc6e834a 100644 --- a/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap +++ b/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap @@ -161,12 +161,6 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match id="searchInput" onChange={[Function]} placeholder="Search Outgoing OAuth Connections" - style={ - Object { - "flexGrow": 0, - "flexShrink": 0, - } - } type="search" value="" /> @@ -223,7 +217,9 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
- +
+
+
+ + + 0 - 0 of 0 + + + + +
+
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 76b886dc37..9ae773205d 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -2961,6 +2961,9 @@ "avatar.alt": "{username} profile image", "avatars.overflowUnnamedOnly": "{overflowUnnamedCount, plural, =1 {one other} other {# 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_navbar.back": "Back", "backstage_navbar.backToMattermost": "Back to {siteName}", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/integrations.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/integrations.ts index 6e5f27ce88..1dfeab574b 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/integrations.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/integrations.ts @@ -7,6 +7,7 @@ export default keyMirror({ RECEIVED_INCOMING_HOOK: null, RECEIVED_INCOMING_HOOKS: null, + RECEIVED_INCOMING_HOOKS_TOTAL_COUNT: null, DELETED_INCOMING_HOOK: null, RECEIVED_OUTGOING_HOOK: null, RECEIVED_OUTGOING_HOOKS: null, diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.test.ts index 7968346a31..60cab31a00 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.test.ts @@ -86,17 +86,31 @@ describe('Actions.Integrations', () => { } as IncomingWebhook, )); + /* Test with include_total_count being set to false */ nock(Client4.getBaseRoute()). get('/hooks/incoming'). query(true). reply(200, [created]); - await store.dispatch(Actions.getIncomingHooks(TestHelper.basicTeam!.id)); - const state = store.getState(); + const response = await store.dispatch(Actions.getIncomingHooks(TestHelper.basicTeam!.id)); + expect(response.data).toBeTruthy(); + expect(response.data[0].id === created.id).toBeTruthy(); + const state = store.getState(); const hooks = state.entities.integrations.incomingHooks; expect(hooks).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 () => { diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.ts index 128ee60b81..ab99625709 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/integrations.ts @@ -1,9 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {AnyAction} from 'redux'; 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 {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) { - return bindClientFunc({ - clientFunc: Client4.getIncomingWebhooks, - onSuccess: [IntegrationTypes.RECEIVED_INCOMING_HOOKS], - params: [ - teamId, - page, - perPage, - ], - }); +export function getIncomingHooks(teamId = '', page = 0, perPage: number = General.PAGE_SIZE_DEFAULT, includeTotalCount = false): ActionFuncAsync { + return async (dispatch, getState) => { + let data; + + try { + data = await Client4.getIncomingWebhooks(teamId, page, perPage, includeTotalCount); + } catch (error) { + 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 { diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/integrations.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/integrations.ts index ddedfd2d52..6ca7670a75 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/integrations.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/integrations.ts @@ -52,6 +52,19 @@ function incomingHooks(state: IDMappedObjects = {}, 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 = {}, action: AnyAction) { switch (action.type) { 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 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 outgoingHooks, diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/integrations.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/integrations.ts index 0834698df3..eb1f6eb7ba 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/integrations.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/integrations.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // 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 {IDMappedObjects} from '@mattermost/types/utilities'; @@ -14,6 +14,10 @@ export function getIncomingHooks(state: GlobalState) { return state.entities.integrations.incomingHooks; } +export function getIncomingHooksTotalCount(state: GlobalState) { + return state.entities.integrations.incomingHooksTotalCount; +} + export function getOutgoingHooks(state: GlobalState) { return state.entities.integrations.outgoingHooks; } @@ -30,6 +34,17 @@ export function getOutgoingOAuthConnections(state: GlobalState) { 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( 'getAppsOAuthAppIDs', appsEnabled, diff --git a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts index 3d44279efa..7764c4b06a 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts @@ -125,6 +125,7 @@ const state: GlobalState = { }, integrations: { incomingHooks: {}, + incomingHooksTotalCount: 0, outgoingHooks: {}, oauthApps: {}, systemCommands: {}, diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 2da7d6e399..70bdfe96a3 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -87,6 +87,7 @@ import type { CommandResponse, DialogSubmission, IncomingWebhook, + IncomingWebhooksWithCount, OAuthApp, OutgoingOAuthConnection, 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 = { page, per_page: perPage, + include_total_count: includeTotalCount, }; if (teamId) { queryParams.team_id = teamId; } - return this.doFetch( + return this.doFetch( `${this.getIncomingHooksRoute()}${buildQueryString(queryParams)}`, {method: 'get'}, ); diff --git a/webapp/platform/types/src/integrations.ts b/webapp/platform/types/src/integrations.ts index 4e9d9725d0..582db3096f 100644 --- a/webapp/platform/types/src/integrations.ts +++ b/webapp/platform/types/src/integrations.ts @@ -19,6 +19,11 @@ export type IncomingWebhook = { channel_locked: boolean; }; +export type IncomingWebhooksWithCount = { + incoming_webhooks: IncomingWebhook[]; + total_count: number; +}; + export type OutgoingWebhook = { id: string; token: string; @@ -121,6 +126,7 @@ export type OutgoingOAuthConnection = { export type IntegrationsState = { incomingHooks: IDMappedObjects; + incomingHooksTotalCount: number; outgoingHooks: IDMappedObjects; oauthApps: IDMappedObjects; outgoingOAuthConnections: IDMappedObjects;