diff --git a/server/http.go b/server/http.go index 276feb9a1..ea4d0823a 100644 --- a/server/http.go +++ b/server/http.go @@ -43,7 +43,9 @@ const ( routeAPIUserInfo = "/userinfo" routeAPISubscribeWebhook = "/webhook" routeAPISubscriptionsChannel = "/subscriptions/channel" + routeAPISubscriptionTemplates = "/subscription-templates" routeAPISubscriptionsChannelWithID = routeAPISubscriptionsChannel + "/{id:[A-Za-z0-9]+}" + routeAPISubscriptionTemplatesWithID = routeAPISubscriptionTemplates + "/{id:[A-Za-z0-9]+}" routeAPISettingsInfo = "/settingsinfo" routeIssueTransition = "/transition" routeAPIUserDisconnect = "/api/v3/disconnect" @@ -150,6 +152,12 @@ func (p *Plugin) initializeRouter() { apiRouter.HandleFunc(routeAPISubscriptionsChannel, p.checkAuth(p.handleResponse(p.httpChannelCreateSubscription))).Methods(http.MethodPost) apiRouter.HandleFunc(routeAPISubscriptionsChannel, p.checkAuth(p.handleResponse(p.httpChannelEditSubscription))).Methods(http.MethodPut) apiRouter.HandleFunc(routeAPISubscriptionsChannelWithID, p.checkAuth(p.handleResponse(p.httpChannelDeleteSubscription))).Methods(http.MethodDelete) + + // Subscription Templates + apiRouter.HandleFunc(routeAPISubscriptionTemplates, p.checkAuth(p.handleResponse(p.httpCreateSubscriptionTemplate))).Methods(http.MethodPost) + apiRouter.HandleFunc(routeAPISubscriptionTemplates, p.checkAuth(p.handleResponse(p.httpEditSubscriptionTemplates))).Methods(http.MethodPut) + apiRouter.HandleFunc(routeAPISubscriptionTemplatesWithID, p.checkAuth(p.handleResponse(p.httpDeleteSubscriptionTemplate))).Methods(http.MethodDelete) + apiRouter.HandleFunc(routeAPISubscriptionTemplates, p.checkAuth(p.handleResponse(p.httpGetSubscriptionTemplates))).Methods(http.MethodGet) } func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { diff --git a/server/http_test.go b/server/http_test.go index c7ade75e5..109d5e430 100644 --- a/server/http_test.go +++ b/server/http_test.go @@ -23,6 +23,7 @@ import ( const TestDataLongSubscriptionName = `aaaaaaaaaabbbbbbbbbbccccccccccddddddddddaaaaaaaaaabbbbbbbbbbccccccccccddddddddddaaaaaaaaaabbbbbbbbbbccccccccccddddddddddaaaaaaaaaabbbbbbbbbbccccccccccddddddddddaaaaaaaaaabbbbbbbbbbccccccccccddddddddddaaaaaaaaaabbbbbbbbbbccccccccccddddddddddaaaaaaaaaabbbbbbbbbbccccccccccdddddddddd` var testSubKey = keyWithInstanceID(mockInstance1URL, JiraSubscriptionsKey) +var testTemplateKey = keyWithInstanceID(mockInstance1URL, templateKey) func checkSubscriptionsEqual(t *testing.T, ls1 []ChannelSubscription, ls2 []ChannelSubscription) { assert.Equal(t, len(ls1), len(ls2)) @@ -45,6 +46,27 @@ func checkSubscriptionsEqual(t *testing.T, ls1 []ChannelSubscription, ls2 []Chan } } +func checkSubscriptionTemplatesEqual(t *testing.T, st1 []SubscriptionTemplate, st2 []SubscriptionTemplate) { + assert.Equal(t, len(st1), len(st2)) + + for _, a := range st1 { + match := false + for _, b := range st2 { + if a.ID == b.ID { + match = true + assert.True(t, a.Filters.Projects.Equals(b.Filters.Projects)) + assert.True(t, a.Filters.IssueTypes.Equals(b.Filters.IssueTypes)) + assert.True(t, a.Filters.Events.Equals(b.Filters.Events)) + break + } + } + + if !match { + assert.Fail(t, "Subscription template arrays are not equal") + } + } +} + func checkNotSubscriptions(subsToCheck []ChannelSubscription, existing *Subscriptions, t *testing.T) func(api *plugintest.API) { return func(api *plugintest.API) { var existingBytes []byte @@ -76,6 +98,36 @@ func checkNotSubscriptions(subsToCheck []ChannelSubscription, existing *Subscrip } } +func checkNotSubscriptionTemplates(templatesToCheck []SubscriptionTemplate, existing *Templates, t *testing.T) func(api *plugintest.API) { + return func(api *plugintest.API) { + var existingBytes []byte + if existing != nil { + var err error + existingBytes, err = json.Marshal(existing) + assert.Nil(t, err) + } + + api.On("HasPermissionTo", mock.AnythingOfType("string"), mock.Anything).Return(true) + api.On("KVGet", testTemplateKey).Return(existingBytes, nil) + api.On("KVCompareAndSet", testTemplateKey, existingBytes, mock.MatchedBy(func(data []byte) bool { + t.Log(string(data)) + var savedTemplates Templates + err := json.Unmarshal(data, &savedTemplates) + assert.Nil(t, err) + + for _, templateToCheck := range templatesToCheck { + for _, savedSub := range savedTemplates.Templates.ByID { + if templateToCheck.ID == savedSub.ID { + return false + } + } + } + + return true + })).Return(true, nil) + } +} + func checkHasSubscriptions(subsToCheck []ChannelSubscription, existing *Subscriptions, t *testing.T) func(api *plugintest.API) { return func(api *plugintest.API) { var existingBytes []byte @@ -125,6 +177,46 @@ func checkHasSubscriptions(subsToCheck []ChannelSubscription, existing *Subscrip } } +func checkHasSubscriptionTemplates(templatesToCheck []SubscriptionTemplate, existing *Templates, t *testing.T) func(api *plugintest.API) { + return func(api *plugintest.API) { + var existingBytes []byte + if existing != nil { + var err error + existingBytes, err = json.Marshal(existing) + assert.Nil(t, err) + } + + api.On("HasPermissionTo", mock.AnythingOfType("string"), mock.Anything).Return(true) + api.On("KVGet", testTemplateKey).Return(existingBytes, nil) + api.On("KVCompareAndSet", testTemplateKey, existingBytes, mock.MatchedBy(func(data []byte) bool { + t.Log(string(data)) + var savedTemplates Templates + err := json.Unmarshal(data, &savedTemplates) + assert.Nil(t, err) + + for _, templateToCheck := range templatesToCheck { + var foundSub *SubscriptionTemplate + for _, savedSub := range savedTemplates.Templates.ByID { + if templateToCheck.Filters.Projects.Equals(savedSub.Filters.Projects) && + templateToCheck.Filters.IssueTypes.Equals(savedSub.Filters.IssueTypes) && + templateToCheck.Filters.Events.Equals(savedSub.Filters.Events) { + savedSub := savedSub // fix gosec G601 + foundSub = &savedSub + break + } + } + + // Check subscription exists + if foundSub == nil { + return false + } + } + + return true + })).Return(true, nil) + } +} + func hasSubscriptions(subscriptions []ChannelSubscription, t *testing.T) func(api *plugintest.API) { return func(api *plugintest.API) { subs := withExistingChannelSubscriptions(subscriptions) @@ -138,6 +230,26 @@ func hasSubscriptions(subscriptions []ChannelSubscription, t *testing.T) func(ap } } +func hasSubscriptionTemplates(templates []SubscriptionTemplate, t *testing.T) func(api *plugintest.API) { + return func(api *plugintest.API) { + templates := withExistingChannelSubscriptionTemplates(templates) + + existingBytes, err := json.Marshal(&templates) + assert.Nil(t, err) + + api.On("HasPermissionTo", mock.AnythingOfType("string"), mock.Anything).Return(true) + api.On("KVGet", testTemplateKey).Return(existingBytes, nil) + } +} + +func getMockSubscriptionFilter(event string) *SubscriptionFilters { + return &SubscriptionFilters{ + Events: NewStringSet(event), + Projects: NewStringSet("myproject"), + IssueTypes: NewStringSet("10001"), + } +} + func TestSubscribe(t *testing.T) { for name, tc := range map[string]struct { subscription string @@ -854,3 +966,492 @@ func TestGetSubscriptionsForChannel(t *testing.T) { }) } } + +func TestDeleteSubscriptionTemplate(t *testing.T) { + for name, tc := range map[string]struct { + templateID string + expectedStatusCode int + skipAuthorize bool + apiCalls func(*plugintest.API) + }{ + "Invalid": { + templateID: "mockTemplateID1", + expectedStatusCode: http.StatusBadRequest, + }, + "Not Authorized": { + templateID: model.NewId(), + expectedStatusCode: http.StatusUnauthorized, + skipAuthorize: true, + }, + "Successful delete": { + templateID: "mockTemplateID1aaaaaaaaaaa", + expectedStatusCode: http.StatusOK, + apiCalls: checkNotSubscriptionTemplates([]SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }, + withExistingChannelSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + { + ID: "mockTemplateID2___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }), t), + }, + } { + t.Run(name, func(t *testing.T) { + api := &plugintest.API{} + p := Plugin{} + + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) + api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) + api.On("LogWarn", mockAnythingOfTypeBatch("string", 11)...).Return() + api.On("LogWarn", mockAnythingOfTypeBatch("string", 7)...).Return() + api.On("GetChannelMember", mockAnythingOfTypeBatch("string", 2)...).Return(&model.ChannelMember{}, (*model.AppError)(nil)) + api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil) + api.On("SendEphemeralPost", mock.Anything, mock.Anything).Return(nil) + + if tc.apiCalls != nil { + tc.apiCalls(api) + } + + p.updateConfig(func(conf *config) { + conf.Secret = someSecret + }) + + p.initializeRouter() + p.SetAPI(api) + p.client = pluginapi.NewClient(api, p.Driver) + p.userStore = mockUserStore{} + p.instanceStore = p.getMockInstanceStoreKV(1) + + w := httptest.NewRecorder() + + request := httptest.NewRequest(http.MethodDelete, + "/api/v2/subscription-templates/"+tc.templateID+"?instance_id="+testInstance1.GetID().String()+"&project_key=myproject", + nil) + + if !tc.skipAuthorize { + request.Header.Set(HeaderMattermostUserID, model.NewId()) + } + + p.ServeHTTP(&plugin.Context{}, w, request) + body, _ := io.ReadAll(w.Result().Body) + t.Log(string(body)) + assert.Equal(t, tc.expectedStatusCode, w.Result().StatusCode) + }) + } +} + +func TestEditSubscriptionTemplate(t *testing.T) { + count := 0 + for name, tc := range map[string]struct { + subscriptionTemplate string + expectedStatusCode int + skipAuthorize bool + apiCalls func(*plugintest.API) + }{ + "Not Authorized": { + subscriptionTemplate: "{}", + expectedStatusCode: http.StatusUnauthorized, + skipAuthorize: true, + }, + "Won't Decode": { + subscriptionTemplate: "{test", + expectedStatusCode: http.StatusBadRequest, + }, + "Editing subscription template": { + subscriptionTemplate: `{ + "instance_id": "https://jiraurl1.com", + "name": "mockName", + "id": "mockTemplateID1___________", + "filters": { + "events": [ + "jira:issue_created" + ], + "projects": [ + "myproject" + ], + "issue_types": [ + "10001" + ], + "fields": [] + } + }`, + expectedStatusCode: http.StatusOK, + apiCalls: checkHasSubscriptionTemplates([]SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }, + withExistingChannelSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }), t), + }, + "Editing subscription template, no name provided": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "", "id": "mockTemplateID1___________", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": ["otherproject"], "issue_types": ["10001"]}}`, + expectedStatusCode: http.StatusInternalServerError, + apiCalls: checkHasSubscriptionTemplates([]SubscriptionTemplate{}, + withExistingChannelSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }), t), + }, + "Editing subscription template, name too long": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "` + TestDataLongSubscriptionName + `", "id": "mockTemplateID1___________", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": ["otherproject"], "issue_types": ["10001"]}}`, + expectedStatusCode: http.StatusInternalServerError, + apiCalls: checkHasSubscriptionTemplates([]SubscriptionTemplate{}, + withExistingChannelSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }), t), + }, + "Editing subscription template, no project provided": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "mockName", "id": "mockTemplateID1___________", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": [], "issue_types": ["10001"]}}`, + expectedStatusCode: http.StatusInternalServerError, + apiCalls: checkHasSubscriptionTemplates([]SubscriptionTemplate{}, + withExistingChannelSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }), t), + }, + "Editing subscription template, no events provided": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "mockName", "id": "mockTemplateID1___________", "channel_id": "mockChannelID_____________", "filters": {"events": [], "projects": ["otherproject"], "issue_types": ["10001"]}}`, + expectedStatusCode: http.StatusInternalServerError, + apiCalls: checkHasSubscriptionTemplates([]SubscriptionTemplate{}, + withExistingChannelSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }), t), + }, + "Editing subscription template, no issue types provided": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "mockName", "id": "mockTemplateID1___________", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": ["otherproject"], "issue_types": []}}`, + expectedStatusCode: http.StatusInternalServerError, + apiCalls: checkHasSubscriptionTemplates([]SubscriptionTemplate{}, + withExistingChannelSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }), t), + }, + "Editing subscription template, GetProject mocked error. Existing subscription has a non-existent project.": { + subscriptionTemplate: fmt.Sprintf(`{"instance_id": "https://jiraurl1.com", "id": "mockTemplateID2___________", "name": "subscription name", "channel_id": "channelaaaaaaaaaabbbbbbbbb", "filters": {"events": ["jira:issue_created"], "projects": ["%s"], "issue_types": ["10001"]}}`, nonExistantProjectKey), + expectedStatusCode: http.StatusInternalServerError, + apiCalls: checkHasSubscriptionTemplates([]SubscriptionTemplate{}, + withExistingChannelSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: "mockTemplateID2___________", + Filters: getMockSubscriptionFilter("jira:issue_updated"), + }, + }), t), + }, + "Editing subscription template, GetProject mocked error. Existing subscription has existing project.": { + subscriptionTemplate: fmt.Sprintf(`{"instance_id": "https://jiraurl1.com", "id": "mockTemplateID2___________", "name": "subscription name", "channel_id": "channelaaaaaaaaaabbbbbbbbb", "filters": {"events": ["jira:issue_created"], "projects": ["%s"], "issue_types": ["10001"]}}`, nonExistantProjectKey), + expectedStatusCode: http.StatusInternalServerError, + apiCalls: checkHasSubscriptionTemplates([]SubscriptionTemplate{}, + withExistingChannelSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: "mockTemplateID2___________", + Filters: getMockSubscriptionFilter("jira:issue_updated"), + }, + }), t), + }, + } { + t.Run(name, func(t *testing.T) { + count++ + fmt.Print(count) + api := &plugintest.API{} + p := Plugin{} + + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) + api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) + api.On("LogWarn", mockAnythingOfTypeBatch("string", 7)...).Return() + api.On("GetChannelMember", mockAnythingOfTypeBatch("string", 2)...).Return(&model.ChannelMember{}, (*model.AppError)(nil)) + api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil) + api.On("SendEphemeralPost", mock.Anything, mock.Anything).Return(nil) + + if tc.apiCalls != nil { + tc.apiCalls(api) + } + + p.updateConfig(func(conf *config) { + conf.Secret = someSecret + }) + p.initializeRouter() + p.SetAPI(api) + p.client = pluginapi.NewClient(api, p.Driver) + p.userStore = mockUserStore{} + p.instanceStore = p.getMockInstanceStoreKV(1) + + w := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPut, "/api/v2/subscription-templates", io.NopCloser(bytes.NewBufferString(tc.subscriptionTemplate))) + if !tc.skipAuthorize { + request.Header.Set(HeaderMattermostUserID, model.NewId()) + } + p.ServeHTTP(&plugin.Context{}, w, request) + assert.Equal(t, tc.expectedStatusCode, w.Result().StatusCode) + }) + } +} + +func TestCreateSubscriptionTemplate(t *testing.T) { + for name, tc := range map[string]struct { + subscriptionTemplate string + expectedStatusCode int + skipAuthorize bool + apiCalls func(*plugintest.API) + }{ + "Invalid": { + subscriptionTemplate: "{}", + expectedStatusCode: http.StatusInternalServerError, + }, + "Not Authorized": { + subscriptionTemplate: "{}", + expectedStatusCode: http.StatusUnauthorized, + skipAuthorize: true, + }, + "Initial Subscription Template": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "some name", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": ["myproject"], "issue_types": ["10001"]}}`, + expectedStatusCode: http.StatusOK, + apiCalls: checkHasSubscriptionTemplates([]SubscriptionTemplate{ + { + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }, nil, t), + }, + "Initial Subscription Template, GetProject mocked error": { + subscriptionTemplate: fmt.Sprintf(`{"instance_id": "https://jiraurl1.com", "name": "some name", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": ["%s"], "issue_types": ["10001"]}}`, nonExistantProjectKey), + expectedStatusCode: http.StatusInternalServerError, + apiCalls: hasSubscriptionTemplates([]SubscriptionTemplate{}, t), + }, + "Initial Subscription Template, empty name provided": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": ["myproject"], "issue_types": ["10001"]}}`, + expectedStatusCode: http.StatusInternalServerError, + apiCalls: hasSubscriptionTemplates([]SubscriptionTemplate{}, t), + }, + "Initial Subscription Template, long name provided": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "` + TestDataLongSubscriptionName + `", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": ["myproject"], "issue_types": ["10001"]}}`, + expectedStatusCode: http.StatusInternalServerError, + apiCalls: hasSubscriptionTemplates([]SubscriptionTemplate{}, t), + }, + "Initial Subscription Template, no project provided": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "mockName", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": [], "issue_types": ["10001"]}}`, + expectedStatusCode: http.StatusInternalServerError, + apiCalls: hasSubscriptionTemplates([]SubscriptionTemplate{}, t), + }, + "Initial Subscription Template, no issue types provided": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "mockName", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": ["myproject"], "issue_types": []}}`, + expectedStatusCode: http.StatusInternalServerError, + apiCalls: hasSubscriptionTemplates([]SubscriptionTemplate{}, t), + }, + "Adding to existing templates in a different channel": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "some name", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": ["myproject"], "issue_types": ["10001"]}}`, + expectedStatusCode: http.StatusOK, + apiCalls: checkHasSubscriptionTemplates([]SubscriptionTemplate{ + { + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + { + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }, + withExistingChannelSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: model.NewId(), + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }), t), + }, + "Adding to existing templates in the same channel": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "subscription name", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": ["myproject"], "issue_types": ["10001"]}}`, + expectedStatusCode: http.StatusOK, + apiCalls: checkHasSubscriptionTemplates([]SubscriptionTemplate{ + { + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + { + Filters: getMockSubscriptionFilter("jira:issue_updated"), + }, + }, + withExistingChannelSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: model.NewId(), + Filters: getMockSubscriptionFilter("jira:issue_updated"), + }, + }), t), + }, + "Adding to existing templates with same name in the same channel": { + subscriptionTemplate: `{"instance_id": "https://jiraurl1.com", "name": "SubscriptionName", "channel_id": "mockChannelID_____________", "filters": {"events": ["jira:issue_created"], "projects": ["myproject"], "issue_types": ["10001"]}}`, + expectedStatusCode: http.StatusInternalServerError, + apiCalls: checkHasSubscriptionTemplates([]SubscriptionTemplate{ + { + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }, + withExistingChannelSubscriptionTemplates( + []SubscriptionTemplate{ + { + Name: "SubscriptionName", + ID: model.NewId(), + Filters: getMockSubscriptionFilter("jira:issue_updated"), + }, + }), t), + }, + } { + t.Run(name, func(t *testing.T) { + api := &plugintest.API{} + p := Plugin{} + + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) + api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) + api.On("LogWarn", mockAnythingOfTypeBatch("string", 7)...).Return() + api.On("GetChannelMember", mockAnythingOfTypeBatch("string", 2)...).Return(&model.ChannelMember{}, (*model.AppError)(nil)) + api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil) + api.On("SendEphemeralPost", mock.Anything, mock.Anything).Return(nil) + + if tc.apiCalls != nil { + tc.apiCalls(api) + } + + p.updateConfig(func(conf *config) { + conf.Secret = someSecret + }) + p.initializeRouter() + p.SetAPI(api) + p.client = pluginapi.NewClient(api, p.Driver) + p.userStore = mockUserStore{} + p.instanceStore = p.getMockInstanceStoreKV(1) + + w := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v2/subscription-templates", io.NopCloser(bytes.NewBufferString(tc.subscriptionTemplate))) + if !tc.skipAuthorize { + request.Header.Set(HeaderMattermostUserID, model.NewId()) + } + p.ServeHTTP(&plugin.Context{}, w, request) + body, _ := io.ReadAll(w.Result().Body) + t.Log(string(body)) + assert.Equal(t, tc.expectedStatusCode, w.Result().StatusCode) + }) + } +} + +func TestGetSubscriptionTemplate(t *testing.T) { + for name, tc := range map[string]struct { + expectedStatusCode int + skipAuthorize bool + apiCalls func(*plugintest.API) + returnedSubscriptionTemplates []SubscriptionTemplate + }{ + "Not Authorized": { + expectedStatusCode: http.StatusUnauthorized, + skipAuthorize: true, + }, + "Only Subscription": { + expectedStatusCode: http.StatusOK, + returnedSubscriptionTemplates: []SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }, + apiCalls: hasSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }, t), + }, + "Multiple subscriptions": { + expectedStatusCode: http.StatusOK, + returnedSubscriptionTemplates: []SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + { + ID: "mockTemplateID2___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }, + apiCalls: hasSubscriptionTemplates( + []SubscriptionTemplate{ + { + ID: "mockTemplateID1___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + { + ID: "mockTemplateID2___________", + Filters: getMockSubscriptionFilter("jira:issue_created"), + }, + }, t), + }, + } { + t.Run(name, func(t *testing.T) { + api := &plugintest.API{} + p := Plugin{} + + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) + api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) + api.On("GetChannelMember", mockAnythingOfTypeBatch("string", 2)...).Return(&model.ChannelMember{}, (*model.AppError)(nil)) + + if tc.apiCalls != nil { + tc.apiCalls(api) + } + + p.updateConfig(func(conf *config) { + conf.Secret = someSecret + }) + p.initializeRouter() + p.SetAPI(api) + p.client = pluginapi.NewClient(api, p.Driver) + p.userStore = mockUserStore{} + p.instanceStore = p.getMockInstanceStoreKV(1) + + w := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/v2/subscription-templates?instance_id="+testInstance1.GetID().String()+"&project_key=myproject", nil) + if !tc.skipAuthorize { + request.Header.Set(HeaderMattermostUserID, model.NewId()) + } + p.ServeHTTP(&plugin.Context{}, w, request) + assert.Equal(t, tc.expectedStatusCode, w.Result().StatusCode) + + if tc.returnedSubscriptionTemplates != nil { + subscriptions := []SubscriptionTemplate{} + body, _ := io.ReadAll(w.Result().Body) + err := json.NewDecoder(bytes.NewReader(body)).Decode(&subscriptions) + assert.Nil(t, err) + checkSubscriptionTemplatesEqual(t, tc.returnedSubscriptionTemplates, subscriptions) + } + }) + } +} diff --git a/server/jira_test_util_test.go b/server/jira_test_util_test.go index 503504dda..21f4a3b11 100644 --- a/server/jira_test_util_test.go +++ b/server/jira_test_util_test.go @@ -9,7 +9,10 @@ import ( "path/filepath" ) -const someSecret = "somesecret" +const ( + someSecret = "somesecret" + mockProjectKey = "myproject" +) func getJiraTestData(filename string) ([]byte, error) { f, err := os.Open(filepath.Join("testdata", filename)) @@ -30,3 +33,11 @@ func withExistingChannelSubscriptions(subscriptions []ChannelSubscription) *Subs } return ret } + +func withExistingChannelSubscriptionTemplates(templates []SubscriptionTemplate) *Templates { + ret := NewTemplates() + for i := range templates { + ret.Templates.add(mockProjectKey, &templates[i]) + } + return ret +} diff --git a/server/subscribe.go b/server/subscribe.go old mode 100644 new mode 100755 index caf743888..b3b0933e6 --- a/server/subscribe.go +++ b/server/subscribe.go @@ -25,6 +25,7 @@ import ( const ( JiraSubscriptionsKey = "jirasub" + templateKey = "templates" FilterIncludeAny = "include_any" FilterIncludeAll = "include_all" @@ -32,7 +33,10 @@ const ( FilterEmpty = "empty" FilterIncludeOrEmpty = "include_or_empty" - MaxSubscriptionNameLength = 100 + MaxSubscriptionNameLength = 100 + MaxSubscriptionTemplateNameLength = 100 + + QueryParamProjectKey = "project_key" ) type FieldFilter struct { @@ -56,6 +60,14 @@ type ChannelSubscription struct { InstanceID types.ID `json:"instance_id"` } +type SubscriptionTemplate struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + Filters *SubscriptionFilters `json:"filters"` + Name string `json:"name"` + InstanceID types.ID `json:"instance_id"` +} + type ChannelSubscriptions struct { ByID map[string]ChannelSubscription `json:"by_id"` IDByChannelID map[string]StringSet `json:"id_by_channel_id"` @@ -93,6 +105,18 @@ type Subscriptions struct { Channel *ChannelSubscriptions } +type SubscriptionTemplateCollection map[string]*SubscriptionTemplate + +type SubscriptionTemplates struct { + ByID map[string]SubscriptionTemplate `json:"by_id"` + ByProjectID map[string]SubscriptionTemplateCollection `json:"by_project_id"` +} + +type Templates struct { + PluginVersion string + Templates *SubscriptionTemplates +} + func NewSubscriptions() *Subscriptions { return &Subscriptions{ PluginVersion: manifest.Version, @@ -100,6 +124,20 @@ func NewSubscriptions() *Subscriptions { } } +func NewSubscriptionTemplates() *SubscriptionTemplates { + return &SubscriptionTemplates{ + ByID: map[string]SubscriptionTemplate{}, + ByProjectID: map[string]SubscriptionTemplateCollection{}, + } +} + +func NewTemplates() *Templates { + return &Templates{ + PluginVersion: manifest.Version, + Templates: NewSubscriptionTemplates(), + } +} + func SubscriptionsFromJSON(bytes []byte, instanceID types.ID) (*Subscriptions, error) { var subs *Subscriptions if len(bytes) != 0 { @@ -121,6 +159,20 @@ func SubscriptionsFromJSON(bytes []byte, instanceID types.ID) (*Subscriptions, e return subs, nil } +func SubscriptionTemplatesFromJSON(bytes []byte) (*Templates, error) { + var subs *Templates + if len(bytes) != 0 { + if unmarshalErr := json.Unmarshal(bytes, &subs); unmarshalErr != nil { + return nil, unmarshalErr + } + subs.PluginVersion = manifest.Version + } else { + subs = NewTemplates() + } + + return subs, nil +} + func (p *Plugin) getUserID() string { return p.getConfig().botUserID } @@ -231,6 +283,15 @@ func (p *Plugin) getSubscriptions(instanceID types.ID) (*Subscriptions, error) { } return SubscriptionsFromJSON(data, instanceID) } +func (p *Plugin) getTemplates(instanceID types.ID) (*Templates, error) { + subKey := keyWithInstanceID(instanceID, templateKey) + data, appErr := p.API.KVGet(subKey) + if appErr != nil { + return nil, appErr + } + + return SubscriptionTemplatesFromJSON(data) +} func (p *Plugin) getSubscriptionsForChannel(instanceID types.ID, channelID string) ([]ChannelSubscription, error) { subs, err := p.getSubscriptions(instanceID) @@ -250,6 +311,25 @@ func (p *Plugin) getSubscriptionsForChannel(instanceID types.ID, channelID strin return channelSubscriptions, nil } +func (p *Plugin) getSubscriptionTemplatesForInstance(instanceID types.ID) (*Templates, error) { + subs, err := p.getTemplates(instanceID) + if err != nil { + return nil, err + } + + return subs, nil +} + +func (p *Plugin) getSubscriptionTemplatesByID(instanceID, templateID types.ID) (*SubscriptionTemplate, error) { + subs, err := p.getTemplates(instanceID) + if err != nil { + return nil, err + } + + sub := subs.Templates.ByID[string(templateID)] + return &sub, nil +} + func (p *Plugin) getChannelSubscription(instanceID types.ID, subscriptionID string) (*ChannelSubscription, error) { subs, err := p.getSubscriptions(instanceID) if err != nil { @@ -313,6 +393,146 @@ func (p *Plugin) addChannelSubscription(instanceID types.ID, newSubscription *Ch }) } +func (t *SubscriptionTemplates) add(projectKey string, newSubscriptionTemplate *SubscriptionTemplate) { + t.ByID[newSubscriptionTemplate.ID] = *newSubscriptionTemplate + if _, valid := t.ByProjectID[projectKey]; !valid { + t.ByProjectID[projectKey] = make(SubscriptionTemplateCollection) + } + + t.ByProjectID[projectKey][newSubscriptionTemplate.ID] = newSubscriptionTemplate +} + +func (t *SubscriptionTemplates) delete(projectKey, subscriptionTemplateID string) { + delete(t.ByID, subscriptionTemplateID) + delete(t.ByProjectID[projectKey], subscriptionTemplateID) +} + +func (p *Plugin) addSubscriptionTemplate(instanceID types.ID, newSubscriptionTemplate *SubscriptionTemplate, client Client) error { + subKey := keyWithInstanceID(instanceID, templateKey) + return p.client.KV.SetAtomicWithRetries(subKey, func(initialBytes []byte) (interface{}, error) { + oldSubscriptionTemplates, err := SubscriptionTemplatesFromJSON(initialBytes) + if err != nil { + return nil, err + } + + projectKey := "" + if newSubscriptionTemplate.Filters.Projects.Len() == 1 { + projectKey = newSubscriptionTemplate.Filters.Projects.Elems()[0] + } + + if err = p.validateSubscriptionTemplate(newSubscriptionTemplate, instanceID, client, projectKey); err != nil { + return nil, err + } + + newSubscriptionTemplate.ID = model.NewId() + oldSubscriptionTemplates.Templates.add(projectKey, newSubscriptionTemplate) + + modifiedBytes, marshalErr := json.Marshal(&oldSubscriptionTemplates) + if marshalErr != nil { + return nil, marshalErr + } + + return modifiedBytes, nil + }) +} + +func (p *Plugin) editSubscriptionTemplate(instanceID types.ID, modifiedSubscriptionTemplate *SubscriptionTemplate, client Client) error { + subKey := keyWithInstanceID(instanceID, templateKey) + return p.client.KV.SetAtomicWithRetries(subKey, func(initialBytes []byte) (interface{}, error) { + subscriptionTemplates, err := SubscriptionTemplatesFromJSON(initialBytes) + if err != nil { + return nil, err + } + + oldSubscriptionTemplate, ok := subscriptionTemplates.Templates.ByID[modifiedSubscriptionTemplate.ID] + if !ok { + return nil, errors.New("subscription template does not exist") + } + + oldProjectKey := "" + if oldSubscriptionTemplate.Filters.Projects.Len() == 1 { + oldProjectKey = oldSubscriptionTemplate.Filters.Projects.Elems()[0] + } + + newProjectKey := "" + if modifiedSubscriptionTemplate.Filters.Projects.Len() == 1 { + newProjectKey = modifiedSubscriptionTemplate.Filters.Projects.Elems()[0] + } + + if err = p.validateSubscriptionTemplate(modifiedSubscriptionTemplate, instanceID, client, newProjectKey); err != nil { + return nil, err + } + + subscriptionTemplates.Templates.delete(oldProjectKey, oldSubscriptionTemplate.ID) + subscriptionTemplates.Templates.add(newProjectKey, modifiedSubscriptionTemplate) + + modifiedBytes, marshalErr := json.Marshal(&subscriptionTemplates) + if marshalErr != nil { + return nil, marshalErr + } + + return modifiedBytes, nil + }) +} + +func (p *Plugin) removeSubscriptionTemplate(instanceID types.ID, subscriptionTemplateID, projectKey string) error { + subKey := keyWithInstanceID(instanceID, templateKey) + return p.client.KV.SetAtomicWithRetries(subKey, func(initialBytes []byte) (interface{}, error) { + oldSubscriptionTemplates, err := SubscriptionTemplatesFromJSON(initialBytes) + if err != nil { + return nil, err + } + + oldSubscriptionTemplates.Templates.delete(projectKey, subscriptionTemplateID) + + modifiedBytes, marshalErr := json.Marshal(&oldSubscriptionTemplates) + if marshalErr != nil { + return nil, marshalErr + } + + return modifiedBytes, nil + }) +} + +func (p *Plugin) validateSubscriptionTemplate(subscriptionTemplate *SubscriptionTemplate, instanceID types.ID, client Client, projectKey string) error { + if len(subscriptionTemplate.Name) == 0 { + return errors.New("please provide a name for the subscription") + } + + if len(subscriptionTemplate.Name) >= MaxSubscriptionTemplateNameLength { + return errors.Errorf("please provide a name less than %d characters", MaxSubscriptionTemplateNameLength) + } + + if len(subscriptionTemplate.Filters.Events) == 0 { + return errors.New("please provide at least one event type") + } + + if len(subscriptionTemplate.Filters.IssueTypes) == 0 { + return errors.New("please provide at least one issue type") + } + + if (len(subscriptionTemplate.Filters.Projects)) == 0 { + return errors.New("please provide a project identifier") + } + + if _, err := client.GetProject(projectKey); err != nil { + return errors.WithMessagef(err, "failed to get project %q", projectKey) + } + + templates, err := p.getSubscriptionTemplatesForInstance(instanceID) + if err != nil { + return err + } + + for _, template := range templates.Templates.ByProjectID[projectKey] { + if template.Name == subscriptionTemplate.Name && template.ID != subscriptionTemplate.ID { + return errors.Errorf("Subscription name, '%s', already exists. Please choose another name.", subscriptionTemplate.Name) + } + } + + return nil +} + func (p *Plugin) validateSubscription(instanceID types.ID, subscription *ChannelSubscription, client Client) error { if len(subscription.Name) == 0 { return errors.New("please provide a name for the subscription") @@ -1005,3 +1225,152 @@ func (p *Plugin) httpChannelGetSubscriptions(w http.ResponseWriter, r *http.Requ return respondJSON(w, subscriptions) } + +func (p *Plugin) httpGetSubscriptionTemplates(w http.ResponseWriter, r *http.Request) (int, error) { + fmt.Print("/n httpGetSubscriptionTemplates") + mattermostUserID := r.Header.Get("Mattermost-User-Id") + instanceID := types.ID(r.FormValue(QueryParamInstanceID)) + if len(instanceID) < 2 { + return respondErr(w, http.StatusBadRequest, errors.New("bad or missing instance id")) + } + + subscriptionTemplates, err := p.getSubscriptionTemplatesForInstance(instanceID) + if err != nil { + return respondErr(w, http.StatusInternalServerError, errors.Wrap(err, "unable to get subscription templates")) + } + + subTemplates := make([]*SubscriptionTemplate, 0) + + projectKey := r.FormValue(QueryParamProjectKey) + if len(projectKey) < 1 { + client, _, _, err := p.getClient(instanceID, types.ID(mattermostUserID)) + if err != nil { + return respondErr(w, http.StatusInternalServerError, err) + } + + pList, err := client.ListProjects("", -1, false) + if err != nil { + return respondErr(w, http.StatusInternalServerError, err) + } + + for _, project := range pList { + listSubscriptionTemplate := subscriptionTemplates.Templates.ByProjectID[project.Key] + for _, subTemplate := range listSubscriptionTemplate { + subTemplates = append(subTemplates, subTemplate) + } + } + } else { + for _, subTemplate := range subscriptionTemplates.Templates.ByProjectID[projectKey] { + subTemplates = append(subTemplates, subTemplate) + } + } + + return respondJSON(w, subTemplates) +} + +func (p *Plugin) httpEditSubscriptionTemplates(w http.ResponseWriter, r *http.Request) (int, error) { + mattermostUserID := r.Header.Get("Mattermost-User-Id") + subscriptionTemplate := SubscriptionTemplate{} + if err := json.NewDecoder(r.Body).Decode(&subscriptionTemplate); err != nil { + return respondErr(w, http.StatusBadRequest, errors.WithMessage(err, "failed to decode the incoming request")) + } + + client, _, connection, err := p.getClient(subscriptionTemplate.InstanceID, types.ID(mattermostUserID)) + if err != nil { + return respondErr(w, http.StatusInternalServerError, err) + } + + if err = p.editSubscriptionTemplate(subscriptionTemplate.InstanceID, &subscriptionTemplate, client); err != nil { + return respondErr(w, http.StatusInternalServerError, err) + } + + _ = p.API.SendEphemeralPost(mattermostUserID, &model.Post{ + UserId: p.getConfig().botUserID, + ChannelId: subscriptionTemplate.ChannelID, + Message: fmt.Sprintf("Jira subscription template, %q, was updated by %s", subscriptionTemplate.Name, connection.DisplayName), + }) + + code, err := respondJSON(w, &subscriptionTemplate) + if err != nil { + return code, err + } + + return http.StatusOK, nil +} + +func (p *Plugin) httpCreateSubscriptionTemplate(w http.ResponseWriter, r *http.Request) (int, error) { + mattermostUserID := r.Header.Get("Mattermost-User-Id") + subscriptionTemplate := SubscriptionTemplate{} + if err := json.NewDecoder(r.Body).Decode(&subscriptionTemplate); err != nil { + return respondErr(w, http.StatusBadRequest, errors.WithMessage(err, "failed to decode incoming request")) + } + + client, _, connection, err := p.getClient(subscriptionTemplate.InstanceID, types.ID(mattermostUserID)) + if err != nil { + return respondErr(w, http.StatusInternalServerError, err) + } + + if err = p.addSubscriptionTemplate(subscriptionTemplate.InstanceID, &subscriptionTemplate, client); err != nil { + return respondErr(w, http.StatusInternalServerError, err) + } + + _ = p.API.SendEphemeralPost(mattermostUserID, &model.Post{ + UserId: p.getConfig().botUserID, + ChannelId: subscriptionTemplate.ChannelID, + Message: fmt.Sprintf("Jira subscription template, %q, was added by %s", subscriptionTemplate.Name, connection.DisplayName), + }) + + code, err := respondJSON(w, &subscriptionTemplate) + if err != nil { + return code, err + } + + return http.StatusCreated, nil +} + +func (p *Plugin) httpDeleteSubscriptionTemplate(w http.ResponseWriter, r *http.Request) (int, error) { + mattermostUserID := r.Header.Get("Mattermost-User-Id") + + params := mux.Vars(r) + subscriptionTemplateID := params["id"] + if len(subscriptionTemplateID) != 26 { + return respondErr(w, http.StatusBadRequest, errors.New("bad subscription id")) + } + + instanceID := types.ID(r.FormValue(QueryParamInstanceID)) + if len(instanceID) < 2 { + return respondErr(w, http.StatusBadRequest, errors.New("bad or missing instance id")) + } + + projectKey := r.FormValue(QueryParamProjectKey) + if projectKey == "" { + return respondErr(w, http.StatusBadRequest, errors.New("missing project key")) + } + + subscriptionTemplate, err := p.getSubscriptionTemplatesByID(instanceID, types.ID(subscriptionTemplateID)) + if err != nil { + return respondErr(w, http.StatusInternalServerError, errors.Wrap(err, "unable to find the subscription template")) + } + + _, _, connection, err := p.getClient(instanceID, types.ID(mattermostUserID)) + if err != nil { + return respondErr(w, http.StatusInternalServerError, err) + } + + if rErr := p.removeSubscriptionTemplate(instanceID, subscriptionTemplateID, projectKey); rErr != nil { + return respondErr(w, http.StatusInternalServerError, errors.Wrap(err, "unable to remove channel subscription template")) + } + + _ = p.API.SendEphemeralPost(mattermostUserID, &model.Post{ + UserId: p.getConfig().botUserID, + ChannelId: subscriptionTemplate.ChannelID, + Message: fmt.Sprintf("Jira subscription template, %q, was removed by %s", subscriptionTemplate.Name, connection.DisplayName), + }) + + code, err := respondJSON(w, map[string]interface{}{model.STATUS: model.StatusOk}) + if err != nil { + return code, err + } + + return http.StatusOK, nil +} diff --git a/server/subscribe_test.go b/server/subscribe_test.go index 6ebd8974e..298f7d571 100644 --- a/server/subscribe_test.go +++ b/server/subscribe_test.go @@ -513,7 +513,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Projects: NewStringSet("TES"), IssueTypes: NewStringSet("10001"), }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -554,7 +554,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Projects: NewStringSet(), IssueTypes: NewStringSet("10001"), }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -580,7 +580,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Projects: NewStringSet("TES", "OTHER"), IssueTypes: NewStringSet("10001"), }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -621,7 +621,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Projects: NewStringSet("TES"), IssueTypes: NewStringSet(), }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -662,7 +662,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Projects: NewStringSet("TES"), IssueTypes: NewStringSet("10001"), }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -713,7 +713,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Inclusion: "include_any", }}, }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -739,7 +739,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Projects: NewStringSet("TES"), IssueTypes: NewStringSet("10001"), }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -765,7 +765,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Projects: NewStringSet("TES"), IssueTypes: NewStringSet("10001"), }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -822,7 +822,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Projects: NewStringSet("TES"), IssueTypes: NewStringSet("10001"), }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -848,7 +848,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Projects: NewStringSet("TES"), IssueTypes: NewStringSet("10001"), }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -883,7 +883,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Projects: NewStringSet("TES"), IssueTypes: NewStringSet("10001"), }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, { ID: "8hduqxgiwiyi5fw3q4d6q56uho", @@ -893,7 +893,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Projects: NewStringSet("TES"), IssueTypes: NewStringSet("10001"), }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -928,7 +928,7 @@ func TestGetChannelsSubscribed(t *testing.T) { Projects: NewStringSet("TES"), IssueTypes: NewStringSet("10001"), }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }}, }, "multiple subscriptions, neither acceptable": { @@ -983,7 +983,7 @@ func TestGetChannelsSubscribed(t *testing.T) { {Key: "status", Values: NewStringSet("10004"), Inclusion: FilterIncludeAny}, }, }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -1033,7 +1033,7 @@ func TestGetChannelsSubscribed(t *testing.T) { {Key: "customfield_10068", Values: NewStringSet("10033", "10034"), Inclusion: FilterIncludeAll}, }, }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -1101,7 +1101,7 @@ func TestGetChannelsSubscribed(t *testing.T) { {Key: "status", Values: NewStringSet("10005"), Inclusion: FilterExcludeAny}, }, }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -1133,7 +1133,7 @@ func TestGetChannelsSubscribed(t *testing.T) { {Key: "customfield_10060", Values: NewStringSet(), Inclusion: FilterEmpty}, }, }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -1183,7 +1183,7 @@ func TestGetChannelsSubscribed(t *testing.T) { {Key: "customfield_10068", Values: NewStringSet("10033"), Inclusion: FilterIncludeAny}, }, }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -1233,7 +1233,7 @@ func TestGetChannelsSubscribed(t *testing.T) { {Key: "customfield_10076", Values: NewStringSet("10039"), Inclusion: FilterIncludeAny}, }, }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -1283,7 +1283,7 @@ func TestGetChannelsSubscribed(t *testing.T) { {Key: "customfield_10078", Values: NewStringSet("some value"), Inclusion: FilterIncludeAny}, }, }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -1333,7 +1333,7 @@ func TestGetChannelsSubscribed(t *testing.T) { {Key: "customfield_10071", Values: NewStringSet("value1"), Inclusion: FilterIncludeAny}, }, }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -1384,7 +1384,7 @@ func TestGetChannelsSubscribed(t *testing.T) { {Key: "customfield_10071", Values: NewStringSet("value1", "value3"), Inclusion: FilterIncludeAny}, }, }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -1434,7 +1434,7 @@ func TestGetChannelsSubscribed(t *testing.T) { {Key: "fixVersions", Values: NewStringSet("10000"), Inclusion: FilterIncludeAny}, }, }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, @@ -1466,7 +1466,7 @@ func TestGetChannelsSubscribed(t *testing.T) { {Key: "Priority", Values: NewStringSet("1"), Inclusion: FilterIncludeAny}, }, }, - InstanceID: "jiraurl1", + InstanceID: "https://jiraurl1.com", }, }, }, diff --git a/webapp/src/action_types/index.ts b/webapp/src/action_types/index.ts old mode 100644 new mode 100755 index 67d654134..e9f199ee1 --- a/webapp/src/action_types/index.ts +++ b/webapp/src/action_types/index.ts @@ -32,6 +32,12 @@ export default { CREATED_CHANNEL_SUBSCRIPTION: `${PluginId}_created_channel_subscription`, EDITED_CHANNEL_SUBSCRIPTION: `${PluginId}_edited_channel_subscription`, + CREATED_SUBSCRIPTION_TEMPLATE: `${PluginId}_created_subscription_template`, + DELETED_SUBSCRIPTION_TEMPLATE: `${PluginId}_deleted_subscription_template`, + EDITED_SUBSCRIPTION_TEMPLATE: `${PluginId}_edited_subscription_template`, + RECEIVED_SUBSCRIPTION_TEMPLATES_PROJECT_KEY: `${PluginId}_received_subscription_templates_project_key`, + RECEIVED_CHANNEL_SUBSCRIPTIONS: `${PluginId}_recevied_channel_subscriptions`, + RECEIVED_SUBSCRIPTION_TEMPLATES: `${PluginId}_recevied_subscription_templates`, DELETED_CHANNEL_SUBSCRIPTION: `${PluginId}_deleted_channel_subscription`, }; diff --git a/webapp/src/actions/index.ts b/webapp/src/actions/index.ts old mode 100644 new mode 100755 index ee1866b8f..e32fd405c --- a/webapp/src/actions/index.ts +++ b/webapp/src/actions/index.ts @@ -17,6 +17,7 @@ import { InstanceType, ProjectMetadata, SearchIssueParams, + SubscriptionTemplate, } from 'types/model'; export const openConnectModal = () => { @@ -225,6 +226,48 @@ export const createChannelSubscription = (subscription: ChannelSubscription) => }; }; +export const createSubscriptionTemplate = (subscriptionTemplate: SubscriptionTemplate) => { + return async (dispatch, getState) => { + const baseUrl = getPluginServerRoute(getState()); + try { + const data = await doFetch(`${baseUrl}/api/v2/subscription-templates`, { + method: 'post', + body: JSON.stringify(subscriptionTemplate), + }); + + dispatch({ + type: ActionTypes.CREATED_SUBSCRIPTION_TEMPLATE, + data, + }); + + return {data}; + } catch (error) { + return {error}; + } + }; +}; + +export const editSubscriptionTemplate = (subscriptionTemplate: SubscriptionTemplate) => { + return async (dispatch, getState) => { + const baseUrl = getPluginServerRoute(getState()); + try { + const data = await doFetch(`${baseUrl}/api/v2/subscription-templates`, { + method: 'put', + body: JSON.stringify(subscriptionTemplate), + }); + + dispatch({ + type: ActionTypes.EDITED_SUBSCRIPTION_TEMPLATE, + data, + }); + + return {data}; + } catch (error) { + return {error}; + } + }; +}; + export const editChannelSubscription = (subscription: ChannelSubscription) => { return async (dispatch, getState) => { const baseUrl = getPluginServerRoute(getState()); @@ -266,6 +309,26 @@ export const deleteChannelSubscription = (subscription: ChannelSubscription) => }; }; +export const deleteSubscriptionTemplate = (subscriptionTemplate: SubscriptionTemplate) => { + return async (dispatch, getState) => { + const baseUrl = getPluginServerRoute(getState()); + try { + await doFetch(`${baseUrl}/api/v2/subscription-templates/${subscriptionTemplate.id}?instance_id=${subscriptionTemplate.instance_id}&project_key=${subscriptionTemplate.filters.projects[0]}`, { + method: 'delete', + }); + + dispatch({ + type: ActionTypes.DELETED_SUBSCRIPTION_TEMPLATE, + data: subscriptionTemplate, + }); + + return {data: subscriptionTemplate}; + } catch (error) { + return {error}; + } + }; +}; + export const fetchChannelSubscriptions = (channelId: string) => { return async (dispatch, getState) => { const baseUrl = getPluginServerRoute(getState()); @@ -295,7 +358,7 @@ export const fetchChannelSubscriptions = (channelId: string) => { } } - if (errors.length > 0 && allResponses.length === errors.length) { + if (errors.length && allResponses.length === errors.length) { return {error: new Error(errors[0])}; } @@ -309,6 +372,66 @@ export const fetchChannelSubscriptions = (channelId: string) => { }; }; +export const fetchAllSubscriptionTemplates = () => { + return async (dispatch, getState) => { + const baseUrl = getPluginServerRoute(getState()); + const connectedInstances = getUserConnectedInstances(getState()); + const instances = connectedInstances.map((instance) => { + return doFetch(`${baseUrl}/api/v2/subscription-templates?instance_id=${instance.instance_id}`, { + method: 'get', + }); + }); + + let allResponses; + try { + allResponses = await Promise.allSettled(instances); + } catch (error) { + return {error}; + } + + const errors: string[] = []; + let data: ChannelSubscription[] = []; + for (const res of allResponses) { + if (res.status === 'rejected') { + errors.push(res.reason); + } else { + data = data.concat(res.value); + } + } + + if (errors.length && allResponses.length === errors.length) { + return {error: new Error(errors[0])}; + } + + dispatch({ + type: ActionTypes.RECEIVED_SUBSCRIPTION_TEMPLATES, + data, + }); + + return {data}; + }; +}; + +export const fetchSubscriptionTemplatesForProjectKey = (instanceId: string, projectKey: string) => { + return async (dispatch, getState) => { + const baseUrl = getPluginServerRoute(getState()); + try { + const data = await doFetch(`${baseUrl}/api/v2/subscription-templates?instance_id=${instanceId}&project_key=${projectKey}`, { + method: 'get', + }); + + dispatch({ + type: ActionTypes.RECEIVED_SUBSCRIPTION_TEMPLATES_PROJECT_KEY, + data, + }); + + return {data}; + } catch (error) { + return {error}; + } + }; +}; + export function getSettings() { return async (dispatch, getState) => { let data; diff --git a/webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap b/webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap index 5a7731a9d..5e0556589 100644 --- a/webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap +++ b/webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap @@ -87,7 +87,7 @@ exports[`components/EditChannelSubscription should match snapshot 1`] = ` confirmButtonClass="btn btn-danger" confirmButtonText="Delete" hideCancel={false} - message="Delete Subscription \\"SubTestName\\"?" + message="Are you sure to delete the subscription \\"SubTestName\\"?" onCancel={[Function]} onConfirm={[Function]} show={false} @@ -214,6 +214,44 @@ exports[`components/EditChannelSubscription should match snapshot after fetching } } /> + <ReactSelectSetting + isLoading={false} + label="Use Template" + name="template" + onChange={[Function]} + options={null} + required={false} + theme={ + Object { + "awayIndicator": "#ffbc42", + "buttonBg": "#166de0", + "buttonColor": "#ffffff", + "centerChannelBg": "#ffffff", + "centerChannelColor": "#3d3c40", + "codeTheme": "github", + "dndIndicator": "#f74343", + "errorTextColor": "#fd5960", + "linkColor": "#2389d7", + "mentionBg": "#ffffff", + "mentionBj": "#ffffff", + "mentionColor": "#145dbf", + "mentionHighlightBg": "#ffe577", + "mentionHighlightLink": "#166de0", + "newMessageSeparator": "#ff8800", + "onlineIndicator": "#06d6a0", + "sidebarBg": "#145dbf", + "sidebarHeaderBg": "#1153ab", + "sidebarHeaderTextColor": "#ffffff", + "sidebarText": "#ffffff", + "sidebarTextActiveBorder": "#579eff", + "sidebarTextActiveColor": "#ffffff", + "sidebarTextHoverBg": "#4578bf", + "sidebarUnreadText": "#ffffff", + "type": "Mattermost", + } + } + value={null} + /> <ReactSelectSetting addValidate={[Function]} isMulti={true} @@ -3794,7 +3832,7 @@ exports[`components/EditChannelSubscription should match snapshot after fetching confirmButtonClass="btn btn-danger" confirmButtonText="Delete" hideCancel={false} - message="Delete Subscription \\"SubTestName\\"?" + message="Are you sure to delete the subscription \\"SubTestName\\"?" onCancel={[Function]} onConfirm={[Function]} show={false} @@ -3928,7 +3966,7 @@ exports[`components/EditChannelSubscription should match snapshot with no issue confirmButtonClass="btn btn-danger" confirmButtonText="Delete" hideCancel={false} - message="Delete Subscription \\"SubTestName\\"?" + message="Are you sure to delete the subscription \\"SubTestName\\"?" onCancel={[Function]} onConfirm={[Function]} show={false} @@ -3983,10 +4021,7 @@ exports[`components/EditChannelSubscription should match snapshot with no subscr className="margin-bottom x3 text-center" > <h2> - Edit Jira Subscription for - <strong> - FGFG - </strong> + Add Subscription Template </h2> </div> <div @@ -4084,7 +4119,7 @@ exports[`components/EditChannelSubscription should match snapshot with no subscr /> <FormButton btnClass="btn-primary" - defaultMessage="Save Subscription" + defaultMessage="Add Template" disabled={true} extraClasses="" onClick={[Function]} @@ -4183,7 +4218,7 @@ exports[`components/EditChannelSubscription should produce subscription error wh confirmButtonClass="btn btn-danger" confirmButtonText="Delete" hideCancel={false} - message="Delete Subscription \\"SubTestName\\"?" + message="Are you sure to delete the subscription \\"SubTestName\\"?" onCancel={[Function]} onConfirm={[Function]} show={false} diff --git a/webapp/src/components/modals/channel_subscriptions/channel_subscriptions.test.tsx b/webapp/src/components/modals/channel_subscriptions/channel_subscriptions.test.tsx index 571ec31b3..71a2db332 100644 --- a/webapp/src/components/modals/channel_subscriptions/channel_subscriptions.test.tsx +++ b/webapp/src/components/modals/channel_subscriptions/channel_subscriptions.test.tsx @@ -18,6 +18,7 @@ describe('components/ChannelSettingsModal', () => { theme: {}, fetchJiraProjectMetadataForAllInstances: jest.fn().mockResolvedValue({}), fetchChannelSubscriptions: jest.fn().mockResolvedValue({}), + fetchAllSubscriptionTemplates: jest.fn().mockResolvedValue({}), sendEphemeralPost: jest.fn(), jiraIssueMetadata: {} as IssueMetadata, jiraProjectMetadata: {} as ProjectMetadata, @@ -50,6 +51,7 @@ describe('components/ChannelSettingsModal', () => { }); await props.fetchChannelSubscriptions(testChannel.id); + await props.fetchAllSubscriptionTemplates(); await props.fetchJiraProjectMetadataForAllInstances(); expect(wrapper.find(ChannelSubscriptionsModalInner).length).toEqual(1); diff --git a/webapp/src/components/modals/channel_subscriptions/channel_subscriptions.tsx b/webapp/src/components/modals/channel_subscriptions/channel_subscriptions.tsx old mode 100644 new mode 100755 index f9fe01684..62f1ef740 --- a/webapp/src/components/modals/channel_subscriptions/channel_subscriptions.tsx +++ b/webapp/src/components/modals/channel_subscriptions/channel_subscriptions.tsx @@ -60,6 +60,8 @@ export default class ChannelSubscriptionsModal extends PureComponent<Props, Stat return; } + const templatesResponse = await this.props.fetchAllSubscriptionTemplates(); + this.setState({showModal: true, allProjectMetadata: projectResponses.data}); }; diff --git a/webapp/src/components/modals/channel_subscriptions/channel_subscriptions_internal.tsx b/webapp/src/components/modals/channel_subscriptions/channel_subscriptions_internal.tsx index 224b0f750..4bef4db05 100644 --- a/webapp/src/components/modals/channel_subscriptions/channel_subscriptions_internal.tsx +++ b/webapp/src/components/modals/channel_subscriptions/channel_subscriptions_internal.tsx @@ -14,6 +14,9 @@ import {SharedProps} from './shared_props'; type State = { creatingSubscription: boolean; selectedSubscription: ChannelSubscription | null; + creatingSubscriptionTemplate: boolean; + selectedSubscriptionTemplate: ChannelSubscription | null; + } type Props = SharedProps & { @@ -24,38 +27,52 @@ export default class ChannelSubscriptionsModalInner extends React.PureComponent< state = { creatingSubscription: false, selectedSubscription: null, + creatingSubscriptionTemplate: false, + selectedSubscriptionTemplate: null, }; showEditChannelSubscription = (subscription: ChannelSubscription): void => { this.setState({selectedSubscription: subscription, creatingSubscription: false}); }; + showEditSubscriptionTemplate = (subscription: ChannelSubscription): void => { + this.setState({selectedSubscriptionTemplate: subscription, creatingSubscriptionTemplate: false}); + }; + showCreateChannelSubscription = (): void => { this.setState({selectedSubscription: null, creatingSubscription: true}); }; + showCreateSubscriptionTemplate = (): void => { + this.setState({selectedSubscriptionTemplate: null, creatingSubscriptionTemplate: true}); + }; + finishEditSubscription = (): void => { - this.setState({selectedSubscription: null, creatingSubscription: false}); + this.setState({selectedSubscription: null, creatingSubscription: false, selectedSubscriptionTemplate: null, creatingSubscriptionTemplate: false}); }; handleBack = (): void => { this.setState({ creatingSubscription: false, selectedSubscription: null, + creatingSubscriptionTemplate: false, + selectedSubscriptionTemplate: null, }); }; render(): JSX.Element { - const {selectedSubscription, creatingSubscription} = this.state; + const {selectedSubscription, creatingSubscription, creatingSubscriptionTemplate, selectedSubscriptionTemplate} = this.state; let form; - if (selectedSubscription || creatingSubscription) { + if (selectedSubscription || creatingSubscription || creatingSubscriptionTemplate || selectedSubscriptionTemplate) { form = ( <EditChannelSubscription {...this.props} finishEditSubscription={this.finishEditSubscription} selectedSubscription={selectedSubscription} creatingSubscription={creatingSubscription} + creatingSubscriptionTemplate={creatingSubscriptionTemplate} + selectedSubscriptionTemplate={selectedSubscriptionTemplate} /> ); } else { @@ -65,12 +82,14 @@ export default class ChannelSubscriptionsModalInner extends React.PureComponent< allProjectMetadata={this.props.allProjectMetadata} showEditChannelSubscription={this.showEditChannelSubscription} showCreateChannelSubscription={this.showCreateChannelSubscription} + showEditSubscriptionTemplate={this.showEditSubscriptionTemplate} + showCreateSubscriptionTemplate={this.showCreateSubscriptionTemplate} /> ); } let backIcon; - if (this.state.creatingSubscription || this.state.selectedSubscription) { + if (this.state.creatingSubscription || this.state.selectedSubscription || this.state.creatingSubscriptionTemplate || this.state.selectedSubscriptionTemplate) { backIcon = ( <BackIcon className='back' diff --git a/webapp/src/components/modals/channel_subscriptions/channel_subscriptions_modal.scss b/webapp/src/components/modals/channel_subscriptions/channel_subscriptions_modal.scss index 93aaa123e..6a3f6d5e4 100644 --- a/webapp/src/components/modals/channel_subscriptions/channel_subscriptions_modal.scss +++ b/webapp/src/components/modals/channel_subscriptions/channel_subscriptions_modal.scss @@ -23,7 +23,10 @@ margin: 2rem 0; } - th, + .th-col { + width: 25%; + } + td { padding: 1rem 0; } @@ -40,6 +43,10 @@ border-color: transparent; } } + + td { + word-break: break-word; + } } &__learnMore { margin-top: 1em; diff --git a/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.test.tsx b/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.test.tsx index 7fc9d945e..c4b32c975 100644 --- a/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.test.tsx +++ b/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.test.tsx @@ -22,6 +22,14 @@ describe('components/EditChannelSubscription', () => { deleteChannelSubscription: jest.fn().mockResolvedValue({}), editChannelSubscription: jest.fn().mockResolvedValue({}), fetchChannelSubscriptions: jest.fn().mockResolvedValue({}), + createSubscriptionTemplate: jest.fn().mockResolvedValue({}), + deleteSubscriptionTemplate: jest.fn().mockResolvedValue({}), + editSubscriptionTemplate: jest.fn().mockResolvedValue({}), + fetchAllSubscriptionTemplates: jest.fn().mockResolvedValue({}), + fetchSubscriptionTemplatesForProjectKey: jest.fn().mockResolvedValue({}), + sendEphemeralPost: jest.fn().mockResolvedValue({}), + getConnected: jest.fn().mockResolvedValue({}), + fetchJiraProjectMetadataForAllInstances: jest.fn().mockResolvedValue({}), fetchJiraIssueMetadataForProjects: jest.fn().mockResolvedValue({data: cloudIssueMetadata}), }; @@ -80,6 +88,7 @@ describe('components/EditChannelSubscription', () => { close: jest.fn(), selectedSubscription: channelSubscriptionForCloud, creatingSubscription: false, + creatingSubscriptionTemplate: false, securityLevelEmptyForJiraSubscriptions: true, }; diff --git a/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx b/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx old mode 100644 new mode 100755 index 1edc90f60..e8107f999 --- a/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx +++ b/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx @@ -64,6 +64,8 @@ export type Props = SharedProps & { finishEditSubscription: () => void; selectedSubscription: ChannelSubscription | null; creatingSubscription: boolean; + creatingSubscriptionTemplate: boolean; + selectedSubscriptionTemplate: ChannelSubscription | null; }; export type State = { @@ -71,12 +73,15 @@ export type State = { instanceID: string; fetchingIssueMetadata: boolean; jiraIssueMetadata: IssueMetadata | null; + templateOptions: ReactSelectOption[] | null; error: string | null; getMetaDataErr: string | null; submitting: boolean; + submittingTemplate: boolean; subscriptionName: string | null; showConfirmModal: boolean; conflictingError: string | null; + selectedTemplateID: string | null; }; export default class EditChannelSubscription extends PureComponent<Props, State> { @@ -98,14 +103,23 @@ export default class EditChannelSubscription extends PureComponent<Props, State> subscriptionName = props.selectedSubscription.name; } + if (props.selectedSubscriptionTemplate) { + filters = Object.assign({}, filters, props.selectedSubscriptionTemplate.filters); + subscriptionName = props.selectedSubscriptionTemplate.name; + } + filters.fields = filters.fields || []; let instanceID = ''; + let fetchingIssueMetadata = false; if (this.props.selectedSubscription) { instanceID = this.props.selectedSubscription.instance_id; } - let fetchingIssueMetadata = false; + if (this.props.selectedSubscriptionTemplate) { + instanceID = this.props.selectedSubscriptionTemplate.instance_id; + } + if (filters.projects.length && instanceID) { fetchingIssueMetadata = true; this.fetchIssueMetadata(filters.projects, instanceID); @@ -115,6 +129,7 @@ export default class EditChannelSubscription extends PureComponent<Props, State> error: null, getMetaDataErr: null, submitting: false, + submittingTemplate: false, filters, fetchingIssueMetadata, jiraIssueMetadata: null, @@ -122,11 +137,29 @@ export default class EditChannelSubscription extends PureComponent<Props, State> showConfirmModal: false, conflictingError: null, instanceID, + selectedTemplateID: null, + templateOptions: null, }; this.validator = new Validator(); } + componentDidMount() { + if (this.props.selectedSubscription) { + const projects = this.props.selectedSubscription.filters.projects; + if (projects.length) { + this.fetchSubscriptionTemplateForProjectKey(this.state.instanceID, projects[0]); + } + } + + if (this.props.selectedSubscriptionTemplate) { + const projects = this.props.selectedSubscriptionTemplate.filters.projects; + if (projects.length) { + this.fetchSubscriptionTemplateForProjectKey(this.state.instanceID, projects[0]); + } + } + } + handleClose = (e?: React.FormEvent) => { if (e && e.preventDefault) { e.preventDefault(); @@ -148,6 +181,16 @@ export default class EditChannelSubscription extends PureComponent<Props, State> } }); } + + if (this.props.selectedSubscriptionTemplate) { + this.props.deleteSubscriptionTemplate(this.props.selectedSubscriptionTemplate).then((res) => { + if (res.error) { + this.setState({error: res.error.message}); + } else { + this.handleClose(); + } + }); + } }; handleCancelDelete = () => { @@ -245,6 +288,26 @@ export default class EditChannelSubscription extends PureComponent<Props, State> }); }; + fetchSubscriptionTemplateForProjectKey = (instanceId: string, projectId: string) => { + this.setState({selectedTemplateID: null, fetchingIssueMetadata: true}); + this.props.fetchSubscriptionTemplatesForProjectKey(instanceId, projectId).then((subs) => { + if (subs.error) { + this.setState({error: subs.error.message}); + return; + } + + const subscriptionTemplate = subs.data as ChannelSubscription[]; + let templateOptions: ReactSelectOption[] | null = null; + if (subscriptionTemplate) { + templateOptions = subscriptionTemplate.map((template: ChannelSubscription) => ( + {label: template.name || template.id, value: template.id} + )); + } + + this.setState({templateOptions, fetchingIssueMetadata: false}); + }); + }; + handleJiraInstanceChange = (instanceID: string) => { if (instanceID === this.state.instanceID) { return; @@ -283,6 +346,11 @@ export default class EditChannelSubscription extends PureComponent<Props, State> this.fetchIssueMetadata(projects, this.state.instanceID); } + if (this.state.instanceID && projectID) { + fetchingIssueMetadata = true; + this.fetchSubscriptionTemplateForProjectKey(this.state.instanceID, projectID); + } + this.setState({ fetchingIssueMetadata, getMetaDataErr: null, @@ -324,9 +392,29 @@ export default class EditChannelSubscription extends PureComponent<Props, State> instance_id: this.state.instanceID, } as ChannelSubscription; - this.setState({submitting: true, error: null}); + if (this.props.selectedSubscriptionTemplate) { + this.setState({submittingTemplate: true, error: null}); + subscription.id = this.props.selectedSubscriptionTemplate.id; + this.props.editSubscriptionTemplate(subscription).then((edited) => { + if (edited.error) { + this.setState({error: edited.error.message, submittingTemplate: false}); + return; + } - if (this.props.selectedSubscription) { + this.handleClose(e); + }); + } else if (this.props.creatingSubscriptionTemplate) { + this.setState({submittingTemplate: true, error: null}); + this.props.createSubscriptionTemplate(subscription).then((created) => { + if (created.error) { + this.setState({error: created.error.message, submittingTemplate: false}); + return; + } + + this.handleClose(e); + }); + } else if (this.props.selectedSubscription) { + this.setState({submitting: true, error: null}); subscription.id = this.props.selectedSubscription.id; this.props.editChannelSubscription(subscription).then((edited) => { if (edited.error) { @@ -336,6 +424,7 @@ export default class EditChannelSubscription extends PureComponent<Props, State> this.handleClose(e); }); } else { + this.setState({submitting: true, error: null}); this.props.createChannelSubscription(subscription).then((created) => { if (created.error) { this.setState({error: created.error.message, submitting: false}); @@ -346,6 +435,15 @@ export default class EditChannelSubscription extends PureComponent<Props, State> } }; + handleTemplateChange = (_: any, templateId: string) => { + const templateChoosen = this.props.subscriptionTemplates.find((template) => template.id === templateId); + this.handleProjectChange(templateChoosen.filters.projects[0]); + this.setState({ + filters: templateChoosen.filters, + selectedTemplateID: templateId, + }); + }; + render(): JSX.Element { const style = getModalStyles(this.props.theme); @@ -375,8 +473,18 @@ export default class EditChannelSubscription extends PureComponent<Props, State> innerComponent = ( <React.Fragment> <ReactSelectSetting - name={'events'} - label={'Events'} + name='template' + label='Use Template' + options={this.state.templateOptions} + onChange={this.handleTemplateChange} + value={this.state.templateOptions && this.state.templateOptions.find((option) => option.value === this.state.selectedTemplateID)} + required={false} + theme={this.props.theme} + isLoading={false} + /> + <ReactSelectSetting + name='events' + label='Events' required={true} onChange={this.handleSettingChange} options={eventOptions} @@ -387,8 +495,8 @@ export default class EditChannelSubscription extends PureComponent<Props, State> removeValidate={this.validator.removeComponent} /> <ReactSelectSetting - name={'issue_types'} - label={'Issue Type'} + name='issue_types' + label='Issue Type' required={true} onChange={this.handleIssueChange} options={issueOptions} @@ -442,8 +550,8 @@ export default class EditChannelSubscription extends PureComponent<Props, State> <React.Fragment> <div className='container-fluid'> <Input - label={'Subscription Name'} - placeholder={'Name'} + label='Subscription Name' + placeholder='Name' type={'input'} maxLength={100} required={true} @@ -476,24 +584,25 @@ export default class EditChannelSubscription extends PureComponent<Props, State> const {showConfirmModal} = this.state; - let confirmDeleteMessage = 'Delete Subscription?'; - if (this.props.selectedSubscription && this.props.selectedSubscription.name) { - confirmDeleteMessage = `Delete Subscription "${this.props.selectedSubscription.name}"?`; + let confirmDeleteMessage = ''; + confirmDeleteMessage = `Are you sure to delete the subscription template ${(this.props.selectedSubscriptionTemplate && this.props.selectedSubscriptionTemplate.name) ? `"${this.props.selectedSubscriptionTemplate.name}"` : ''}?`; + if (this.props.selectedSubscription) { + confirmDeleteMessage = `Are you sure to delete the subscription ${this.props.selectedSubscription.name ? `"${this.props.selectedSubscription.name}"` : ''}?`; } let confirmComponent; - if (this.props.selectedSubscription) { + if (this.props.selectedSubscription || this.props.selectedSubscriptionTemplate) { confirmComponent = ( <ConfirmModal - cancelButtonText={'Cancel'} - confirmButtonText={'Delete'} + cancelButtonText='Cancel' + confirmButtonText='Delete' confirmButtonClass={'btn btn-danger'} hideCancel={false} message={confirmDeleteMessage} onCancel={this.handleCancelDelete} onConfirm={this.handleConfirmDelete} show={showConfirmModal} - title={'Subscription'} + title={this.props.selectedSubscription ? 'Subscription' : 'Subscription Template'} /> ); } @@ -508,13 +617,23 @@ export default class EditChannelSubscription extends PureComponent<Props, State> } const enableSubmitButton = Boolean(this.state.filters.projects[0]); - const enableDeleteButton = Boolean(this.props.selectedSubscription); - - let saveSubscriptionButtonText = 'Save Subscription'; - let headerText = 'Edit Jira Subscription for '; - if (this.props.creatingSubscription) { - saveSubscriptionButtonText = 'Add Subscription'; - headerText = 'Add Jira Subscription in '; + const enableDeleteButton = Boolean(this.props.selectedSubscription || this.props.selectedSubscriptionTemplate); + let saveSubscriptionButtonText = ''; + let headerText = ''; + if (this.props.selectedSubscription || this.props.creatingSubscription) { + saveSubscriptionButtonText = 'Save Subscription'; + headerText = 'Edit Jira Subscription for '; + if (this.props.creatingSubscription) { + saveSubscriptionButtonText = 'Add Subscription'; + headerText = 'Add Jira Subscription in '; + } + } else { + saveSubscriptionButtonText = 'Add Template'; + headerText = 'Add Subscription Template'; + if (this.props.selectedSubscriptionTemplate && this.props.selectedSubscriptionTemplate.name) { + saveSubscriptionButtonText = 'Save Template'; + headerText = 'Edit Subscription Template'; + } } return ( @@ -522,7 +641,7 @@ export default class EditChannelSubscription extends PureComponent<Props, State> role='form' > <div className='margin-bottom x3 text-center'> - <h2>{headerText}<strong>{this.props.channel.display_name}</strong></h2> + {this.props.selectedSubscription || this.props.creatingSubscription ? <h2>{headerText}<strong>{this.props.channel.display_name}</strong></h2> : <h2>{headerText}</h2>} </div> <div style={style.modalBody}> {component} @@ -549,7 +668,7 @@ export default class EditChannelSubscription extends PureComponent<Props, State> onClick={this.handleCreate} disabled={!enableSubmitButton} btnClass='btn-primary' - saving={this.state.submitting} + saving={this.props.creatingSubscriptionTemplate || this.props.selectedSubscriptionTemplate ? this.state.submittingTemplate : this.state.submitting} defaultMessage={saveSubscriptionButtonText} savingMessage='Saving...' /> diff --git a/webapp/src/components/modals/channel_subscriptions/index.ts b/webapp/src/components/modals/channel_subscriptions/index.ts old mode 100644 new mode 100755 index 8e3ddce03..f36df710d --- a/webapp/src/components/modals/channel_subscriptions/index.ts +++ b/webapp/src/components/modals/channel_subscriptions/index.ts @@ -10,12 +10,17 @@ import {isDirectChannel, isGroupChannel} from 'mattermost-redux/utils/channel_ut import { closeChannelSettings, createChannelSubscription, + createSubscriptionTemplate, deleteChannelSubscription, + deleteSubscriptionTemplate, editChannelSubscription, + editSubscriptionTemplate, + fetchAllSubscriptionTemplates, fetchChannelSubscriptions, fetchJiraIssueMetadataForProjects, fetchJiraProjectMetadata, fetchJiraProjectMetadataForAllInstances, + fetchSubscriptionTemplatesForProjectKey, getConnected, sendEphemeralPost, } from 'actions'; @@ -25,6 +30,7 @@ import { getChannelSubscriptions, getInstalledInstances, getPluginSettings, + getSubscriptionTemplates, getUserConnectedInstances, } from 'selectors'; @@ -41,7 +47,7 @@ const mapStateToProps = (state) => { } const channelSubscriptions = getChannelSubscriptions(state)[channelId]; - + const subscriptionTemplates = getSubscriptionTemplates(state).subscriptionTemplates; const installedInstances = getInstalledInstances(state); const connectedInstances = getUserConnectedInstances(state); const pluginSettings = getPluginSettings(state); @@ -50,6 +56,7 @@ const mapStateToProps = (state) => { return { omitDisplayName, channelSubscriptions, + subscriptionTemplates, channel, installedInstances, connectedInstances, @@ -63,9 +70,14 @@ const mapDispatchToProps = (dispatch) => bindActionCreators({ fetchJiraProjectMetadataForAllInstances, fetchJiraIssueMetadataForProjects, createChannelSubscription, + createSubscriptionTemplate, + fetchAllSubscriptionTemplates, + fetchSubscriptionTemplatesForProjectKey, fetchChannelSubscriptions, deleteChannelSubscription, + deleteSubscriptionTemplate, editChannelSubscription, + editSubscriptionTemplate, getConnected, sendEphemeralPost, }, dispatch); diff --git a/webapp/src/components/modals/channel_subscriptions/select_channel_subscription.tsx b/webapp/src/components/modals/channel_subscriptions/select_channel_subscription.tsx old mode 100644 new mode 100755 index bd1a3c223..1bd7cc8b7 --- a/webapp/src/components/modals/channel_subscriptions/select_channel_subscription.tsx +++ b/webapp/src/components/modals/channel_subscriptions/select_channel_subscription.tsx @@ -8,7 +8,9 @@ import {SharedProps} from './shared_props'; type Props = SharedProps & { showEditChannelSubscription: (subscription: ChannelSubscription) => void; + showEditSubscriptionTemplate: (subscription: ChannelSubscription) => void; showCreateChannelSubscription: () => void; + showCreateSubscriptionTemplate: () => void; allProjectMetadata: AllProjectMetadata | null; }; @@ -16,6 +18,7 @@ type State = { error: string | null; showConfirmModal: boolean; subscriptionToDelete: ChannelSubscription | null; + isTemplate: boolean; } export default class SelectChannelSubscriptionInternal extends React.PureComponent<Props, State> { @@ -23,6 +26,7 @@ export default class SelectChannelSubscriptionInternal extends React.PureCompone error: null, showConfirmModal: false, subscriptionToDelete: null, + isTemplate: false, }; handleCancelDelete = (): void => { @@ -31,13 +35,18 @@ export default class SelectChannelSubscriptionInternal extends React.PureCompone handleConfirmDelete = (): void => { this.setState({showConfirmModal: false}); - this.deleteChannelSubscription(this.state.subscriptionToDelete); + if (this.state.isTemplate) { + this.deleteSubscriptionTemplate(this.state.subscriptionToDelete); + } else { + this.deleteChannelSubscription(this.state.subscriptionToDelete); + } }; - handleDeleteChannelSubscription = (sub: ChannelSubscription): void => { + handleDeleteChannelSubscription = (sub: ChannelSubscription, isTemplate = false): void => { this.setState({ showConfirmModal: true, subscriptionToDelete: sub, + isTemplate, }); }; @@ -49,6 +58,14 @@ export default class SelectChannelSubscriptionInternal extends React.PureCompone }); }; + deleteSubscriptionTemplate = (sub: ChannelSubscription): void => { + this.props.deleteSubscriptionTemplate(sub).then((res: {error?: {message: string}}) => { + if (res.error) { + this.setState({error: res.error.message}); + } + }); + }; + getProjectName = (sub: ChannelSubscription): string => { const projectKey = sub.filters.projects[0]; if (!this.props.allProjectMetadata) { @@ -66,7 +83,7 @@ export default class SelectChannelSubscriptionInternal extends React.PureCompone return projectKey; }; - renderRow = (sub: ChannelSubscription): JSX.Element => { + renderRow = (sub: ChannelSubscription, forTemplates = false): JSX.Element => { const projectName = this.getProjectName(sub); const showInstanceColumn = this.props.installedInstances.length > 1; @@ -74,6 +91,13 @@ export default class SelectChannelSubscriptionInternal extends React.PureCompone const alias = this.props.installedInstances.filter((instance) => instance.instance_id === sub.instance_id)[0].alias; const instanceName = alias || sub.instance_id; + if (!forTemplates) { + return this.renderSubscriptionRow(sub, projectName, showInstanceColumn, instanceName); + } + return this.renderSubscriptionTemplateRow(sub, projectName, showInstanceColumn, instanceName); + }; + + renderSubscriptionRow(sub: ChannelSubscription, projectName: string, showInstanceColumn: boolean, instanceName: string): JSX.Element { return ( <tr key={sub.id} @@ -110,11 +134,44 @@ export default class SelectChannelSubscriptionInternal extends React.PureCompone </td> </tr> ); - }; + } + + renderSubscriptionTemplateRow(sub: ChannelSubscription, projectName: string, showInstanceColumn: boolean, instanceName: string): JSX.Element { + return ( + <tr + key={sub.id} + className='select-channel-subscriptions-row' + > + <td>{sub.name || '(no name)'}</td> + <td>{projectName}</td> + {showInstanceColumn && ( + <td>{instanceName}</td> + )} + + <td> + <button + className='style--none color--link' + onClick={() => this.props.showEditSubscriptionTemplate(sub)} + type='button' + > + {'Edit'} + </button> + {' - '} + <button + className='style--none color--link' + onClick={() => this.handleDeleteChannelSubscription(sub, true)} + type='button' + > + {'Delete'} + </button> + </td> + </tr> + ); + } render(): React.ReactElement { - const {channel, channelSubscriptions, omitDisplayName} = this.props; - const {error, showConfirmModal, subscriptionToDelete} = this.state; + const {channel, channelSubscriptions, subscriptionTemplates, omitDisplayName} = this.props; + const {error, showConfirmModal, subscriptionToDelete, isTemplate} = this.state; let errorDisplay = null; if (error) { @@ -123,9 +180,10 @@ export default class SelectChannelSubscriptionInternal extends React.PureCompone ); } - let confirmDeleteMessage = 'Delete Subscription?'; + let confirmDeleteMessage = ''; + confirmDeleteMessage = `Are you sure to delete the subscription ${isTemplate ? 'template' : ''}?`; if (subscriptionToDelete && subscriptionToDelete.name) { - confirmDeleteMessage = `Delete Subscription "${subscriptionToDelete.name}"?`; + confirmDeleteMessage = `Are you sure to delete the subscription ${isTemplate ? 'template' : ''} "${subscriptionToDelete.name}"?`; } let confirmModal = null; @@ -140,7 +198,7 @@ export default class SelectChannelSubscriptionInternal extends React.PureCompone onCancel={this.handleCancelDelete} onConfirm={this.handleConfirmDelete} show={true} - title={'Subscription'} + title={isTemplate ? 'Subscription Template' : 'Subscription'} /> ); } @@ -150,21 +208,39 @@ export default class SelectChannelSubscriptionInternal extends React.PureCompone titleMessage = <h2 className='text-center'>{'Jira Subscriptions'}</h2>; } + const subscriptionTemplateTitle = <h2 className='text-center'>{'Jira Subscription Templates'}</h2>; const showInstanceColumn = this.props.installedInstances.length > 1; let subscriptionRows; + let subscriptionTemplateRows; + const columns = ( + <thead> + <tr> + <th scope='col'>{'Name'}</th> + <th + className='th-col' + scope='col' + >{'Project'}</th> + {showInstanceColumn && + <th + className='th-col' + scope='col' + >{'Instance'}</th> + } + <th + className='th-col' + scope='col' + >{'Actions'}</th> + </tr> + </thead> + ); if (channelSubscriptions.length) { subscriptionRows = ( <table className='table'> - <thead> - <tr> - <th scope='col'>{'Name'}</th> - <th scope='col'>{'Project'}</th> - {showInstanceColumn && <th scope='col'>{'Instance'}</th>} - <th scope='col'>{'Actions'}</th> - </tr> - </thead> + {columns} <tbody> - {channelSubscriptions.map(this.renderRow)} + {channelSubscriptions.map((element) => ( + this.renderRow(element, false) + ))} </tbody> </table> ); @@ -176,6 +252,23 @@ export default class SelectChannelSubscriptionInternal extends React.PureCompone ); } + if (subscriptionTemplates.length) { + subscriptionTemplateRows = ( + <table className='table'> + {columns} + <tbody> + {subscriptionTemplates.map((element) => ( + this.renderRow(element, true) + ))} + </tbody> + </table> + ); + } else { + subscriptionTemplateRows = ( + <p>{'Click "Create Template" to create a subscription template.'}</p> + ); + } + return ( <div> <div className='d-flex justify-content-between align-items-center margin-bottom x3 title-message'> @@ -188,9 +281,20 @@ export default class SelectChannelSubscriptionInternal extends React.PureCompone {'Create Subscription'} </button> </div> + {subscriptionRows} + <div className='d-flex justify-content-between align-items-center margin-bottom x3'> + {subscriptionTemplateTitle} + <button + className='btn btn-primary' + onClick={this.props.showCreateSubscriptionTemplate} + type='button' + > + {'Create Template'} + </button> + </div> + {subscriptionTemplateRows} {confirmModal} {errorDisplay} - {subscriptionRows} </div> ); } diff --git a/webapp/src/components/modals/channel_subscriptions/shared_props.ts b/webapp/src/components/modals/channel_subscriptions/shared_props.ts old mode 100644 new mode 100755 index aca654c90..6935f42e0 --- a/webapp/src/components/modals/channel_subscriptions/shared_props.ts +++ b/webapp/src/components/modals/channel_subscriptions/shared_props.ts @@ -17,15 +17,21 @@ export type SharedProps = { channel: Channel | null; theme: Theme; channelSubscriptions: ChannelSubscription[]; + subscriptionTemplates: ChannelSubscription[]; omitDisplayName: boolean; installedInstances: Instance[]; connectedInstances: Instance[]; createChannelSubscription: (sub: ChannelSubscription) => Promise<APIResponse<{}>>; + createSubscriptionTemplate: (sub: ChannelSubscription) => Promise<APIResponse<{}>>; + deleteSubscriptionTemplate: (sub: ChannelSubscription) => Promise<APIResponse<{}>>; deleteChannelSubscription: (sub: ChannelSubscription) => Promise<APIResponse<{}>>; + editSubscriptionTemplate: (sub: ChannelSubscription) => Promise<APIResponse<{}>>; editChannelSubscription: (sub: ChannelSubscription) => Promise<APIResponse<{}>>; + fetchSubscriptionTemplatesForProjectKey: (instanceId: string, projectKey: string) => Promise<APIResponse<ChannelSubscription[]>>; fetchJiraProjectMetadataForAllInstances: () => Promise<APIResponse<AllProjectMetadata>>; fetchJiraIssueMetadataForProjects: (projectKeys: string[], instanceID: string) => Promise<APIResponse<IssueMetadata>>; fetchChannelSubscriptions: (channelId: string) => Promise<APIResponse<ChannelSubscription[]>>; + fetchAllSubscriptionTemplates: () => Promise<APIResponse<ChannelSubscription[]>>; getConnected: () => Promise<GetConnectedResponse>; close: () => void; sendEphemeralPost: (message: string) => void; diff --git a/webapp/src/reducers/index.js b/webapp/src/reducers/index.js old mode 100644 new mode 100755 index efed52332..81a93e2d4 --- a/webapp/src/reducers/index.js +++ b/webapp/src/reducers/index.js @@ -151,6 +151,61 @@ const channelIdWithSettingsOpen = (state = '', action) => { } }; +const subscriptionTemplates = (state = '', action) => { + switch (action.type) { + case ActionTypes.RECEIVED_SUBSCRIPTION_TEMPLATES: { + const nextState = {...state}; + nextState.subscriptionTemplates = action.data; + return nextState; + } + case ActionTypes.DELETED_SUBSCRIPTION_TEMPLATE: { + const subTemplate = action.data; + const nextState = {...state}; + + nextState.subscriptionTemplates = nextState.subscriptionTemplates.filter((st) => { + return st.id !== subTemplate.id; + }); + + return nextState; + } + case ActionTypes.CREATED_SUBSCRIPTION_TEMPLATE: { + const subTemplate = action.data; + const nextState = {...state}; + nextState.subscriptionTemplates = [...nextState.subscriptionTemplates, subTemplate]; + + return nextState; + } + case ActionTypes.EDITED_SUBSCRIPTION_TEMPLATE: { + const subTemplate = action.data; + const nextState = {...state}; + + const index = nextState.subscriptionTemplates.findIndex((template) => template.id === subTemplate.id); + + const newArray = [...nextState.subscriptionTemplates]; + newArray[index] = subTemplate; + + return { + ...nextState, + subscriptionTemplates: newArray, + }; + } + default: + return state; + } +}; + +const subscriptionTemplatesForProjectKey = (state = '', action) => { + switch (action.type) { + case ActionTypes.RECEIVED_SUBSCRIPTION_TEMPLATES_PROJECT_KEY: { + const nextState = {...state}; + nextState.subscriptionTemplatesForProjectKey = action.data; + return nextState; + } + default: + return state; + } +}; + const channelSubscriptions = (state = {}, action) => { switch (action.type) { case ActionTypes.RECEIVED_CHANNEL_SUBSCRIPTIONS: { @@ -207,5 +262,7 @@ export default combineReducers({ attachCommentToIssueModalVisible, attachCommentToIssueModalForPostId, channelIdWithSettingsOpen, + subscriptionTemplates, + subscriptionTemplatesForProjectKey, channelSubscriptions, }); diff --git a/webapp/src/selectors/index.ts b/webapp/src/selectors/index.ts old mode 100644 new mode 100755 index 0488f67a4..24bbdd79a --- a/webapp/src/selectors/index.ts +++ b/webapp/src/selectors/index.ts @@ -53,6 +53,10 @@ export const getChannelIdWithSettingsOpen = (state) => getPluginState(state).cha export const getChannelSubscriptions = (state) => getPluginState(state).channelSubscriptions; +export const getSubscriptionTemplates = (state) => getPluginState(state).subscriptionTemplates; + +export const getSubscriptionTemplatesForProjectKey = (state) => getPluginState(state).subscriptionTemplatesForProjectKey; + export const isUserConnected = (state) => getUserConnectedInstances(state).length > 0; export const canUserConnect = (state) => getPluginState(state).userCanConnect; diff --git a/webapp/src/types/model.ts b/webapp/src/types/model.ts index cbc4a245a..108af1a50 100644 --- a/webapp/src/types/model.ts +++ b/webapp/src/types/model.ts @@ -172,6 +172,8 @@ export type ChannelSubscription = { instance_id: string; } +export type SubscriptionTemplate = ChannelSubscription + export enum InstanceType { CLOUD = 'cloud', SERVER = 'server',