From bf1afd3a6049bab9cc3c4783f1de2bb075cf862d Mon Sep 17 00:00:00 2001 From: Guiheux Steven Date: Mon, 29 Jun 2020 16:15:32 +0200 Subject: [PATCH] feat(ui): add ascode environment edition in UI (#5269) --- engine/api/api_routes.go | 1 + engine/api/application.go | 14 +- engine/api/ascode/pull_request.go | 15 + engine/api/environment.go | 135 ++++++++ .../api/environment/environment_exporter.go | 4 +- engine/api/environment_ascode_test.go | 254 ++++++++++++++ engine/api/pipeline.go | 18 +- engine/api/workflow/workflow_exporter.go | 2 +- sdk/environment.go | 21 +- ui/src/app/app.component.ts | 5 +- ui/src/app/app.service.ts | 9 +- ui/src/app/event.service.ts | 1 - ui/src/app/model/environment.model.ts | 3 + .../application/application.service.ts | 1 - .../environment/environment.service.ts | 15 + .../save-form/ascode.save-form.component.ts | 18 +- .../ascode/save-form/ascode.save-form.html | 15 +- .../save-modal/ascode.save-modal.component.ts | 24 +- .../ascode/save-modal/ascode.save-modal.html | 19 +- .../variable/list/variable.component.ts | 2 - .../wizard/node-add/node.wizard.component.ts | 4 +- ui/src/app/store/environment.action.ts | 77 +++++ ui/src/app/store/environment.state.spec.ts | 276 ++++++++++++++++ ui/src/app/store/environment.state.ts | 312 ++++++++++++++++++ ui/src/app/store/project.action.ts | 54 --- ui/src/app/store/project.state.spec.ts | 312 +----------------- ui/src/app/store/project.state.ts | 215 ------------ ui/src/app/store/store.module.ts | 4 +- .../show/keys/application.keys.component.ts | 8 +- .../add/environment.add.component.ts | 4 +- .../environment.advanced.component.ts | 14 +- .../show/environment.show.component.ts | 109 ++++-- .../environment/show/environment.show.html | 25 +- .../environment/show/environment.show.scss | 9 + .../show/keys/environment.keys.component.ts | 20 +- .../show/keys/environment.keys.html | 4 +- ui/src/assets/i18n/en.json | 3 +- ui/src/assets/i18n/fr.json | 3 +- 38 files changed, 1323 insertions(+), 706 deletions(-) create mode 100644 engine/api/environment_ascode_test.go create mode 100644 ui/src/app/store/environment.action.ts create mode 100644 ui/src/app/store/environment.state.spec.ts create mode 100644 ui/src/app/store/environment.state.ts diff --git a/engine/api/api_routes.go b/engine/api/api_routes.go index b5b67f9dd7..5b72518c74 100644 --- a/engine/api/api_routes.go +++ b/engine/api/api_routes.go @@ -286,6 +286,7 @@ func (api *API) InitRouter() { r.Handle("/project/{permProjectKey}/environment/import", Scope(sdk.AuthConsumerScopeProject), r.POST(api.importNewEnvironmentHandler, DEPRECATED)) r.Handle("/project/{permProjectKey}/environment/import/{environmentName}", Scope(sdk.AuthConsumerScopeProject), r.POST(api.importIntoEnvironmentHandler, DEPRECATED)) r.Handle("/project/{permProjectKey}/environment/{environmentName}", Scope(sdk.AuthConsumerScopeProject), r.GET(api.getEnvironmentHandler), r.PUT(api.updateEnvironmentHandler), r.DELETE(api.deleteEnvironmentHandler)) + r.Handle("/project/{permProjectKey}/environment/{environmentName}/ascode", Scope(sdk.AuthConsumerScopeProject), r.PUT(api.updateAsCodeEnvironmentHandler)) r.Handle("/project/{permProjectKey}/environment/{environmentName}/usage", Scope(sdk.AuthConsumerScopeProject), r.GET(api.getEnvironmentUsageHandler)) r.Handle("/project/{permProjectKey}/environment/{environmentName}/keys", Scope(sdk.AuthConsumerScopeProject), r.GET(api.getKeysInEnvironmentHandler), r.POST(api.addKeyInEnvironmentHandler)) r.Handle("/project/{permProjectKey}/environment/{environmentName}/keys/{name}", Scope(sdk.AuthConsumerScopeProject), r.DELETE(api.deleteKeyInEnvironmentHandler)) diff --git a/engine/api/application.go b/engine/api/application.go index 359cb582a0..5e1d175581 100644 --- a/engine/api/application.go +++ b/engine/api/application.go @@ -151,17 +151,27 @@ func (api *API) getApplicationHandler() service.Handler { } if app.FromRepository != "" { - proj, err := project.Load(api.mustDB(), projectKey) + proj, err := project.Load(api.mustDB(), projectKey, + project.LoadOptions.WithApplicationWithDeploymentStrategies, + project.LoadOptions.WithPipelines, + project.LoadOptions.WithEnvironments, + project.LoadOptions.WithIntegrations) if err != nil { return err } + wkAscodeHolder, err := workflow.LoadByRepo(ctx, api.mustDB(), *proj, app.FromRepository, workflow.LoadOptions{ WithTemplate: true, }) if err != nil && !sdk.ErrorIs(err, sdk.ErrNotFound) { - return sdk.NewErrorFrom(err, "cannot found workflow holder of the pipeline") + return sdk.NewErrorFrom(err, "cannot found workflow holder of the application") } app.WorkflowAscodeHolder = wkAscodeHolder + + // FIXME from_repository should never be set if the workflow holder was deleted + if app.WorkflowAscodeHolder == nil { + app.FromRepository = "" + } } return service.WriteJSON(w, app, http.StatusOK) diff --git a/engine/api/ascode/pull_request.go b/engine/api/ascode/pull_request.go index 4c5decc841..dec2ad2d35 100644 --- a/engine/api/ascode/pull_request.go +++ b/engine/api/ascode/pull_request.go @@ -23,6 +23,7 @@ const ( PipelineEvent EventType = "pipeline" WorkflowEvent EventType = "workflow" ApplicationEvent EventType = "application" + EnvironmentEvent EventType = "environment" ) type EntityData struct { @@ -200,6 +201,20 @@ func createPullRequest(ctx context.Context, db *gorp.DbMap, store cache.Store, p if !found { asCodeEvent.Data.Applications[ed.ID] = ed.Name } + case EnvironmentEvent: + if asCodeEvent.Data.Environments == nil { + asCodeEvent.Data.Environments = make(map[int64]string) + } + found := false + for k := range asCodeEvent.Data.Environments { + if k == ed.ID { + found = true + break + } + } + if !found { + asCodeEvent.Data.Environments[ed.ID] = ed.Name + } } if err := UpsertEvent(db, asCodeEvent); err != nil { diff --git a/engine/api/environment.go b/engine/api/environment.go index 94f315e7b7..fa9e601d7e 100644 --- a/engine/api/environment.go +++ b/engine/api/environment.go @@ -2,17 +2,23 @@ package api import ( "context" + "fmt" "net/http" "github.com/go-gorp/gorp" "github.com/gorilla/mux" + "github.com/ovh/cds/engine/api/application" + "github.com/ovh/cds/engine/api/ascode" "github.com/ovh/cds/engine/api/environment" "github.com/ovh/cds/engine/api/event" + "github.com/ovh/cds/engine/api/keys" + "github.com/ovh/cds/engine/api/operation" "github.com/ovh/cds/engine/api/project" "github.com/ovh/cds/engine/api/workflow" "github.com/ovh/cds/engine/service" "github.com/ovh/cds/sdk" + "github.com/ovh/cds/sdk/exportentities" "github.com/ovh/cds/sdk/log" ) @@ -73,6 +79,30 @@ func (api *API) getEnvironmentHandler() service.Handler { env.Usage.Workflows = wf } + if env.FromRepository != "" { + proj, err := project.Load(api.mustDB(), projectKey, + project.LoadOptions.WithApplicationWithDeploymentStrategies, + project.LoadOptions.WithPipelines, + project.LoadOptions.WithEnvironments, + project.LoadOptions.WithIntegrations) + if err != nil { + return err + } + + wkAscodeHolder, err := workflow.LoadByRepo(ctx, api.mustDB(), *proj, env.FromRepository, workflow.LoadOptions{ + WithTemplate: true, + }) + if err != nil && !sdk.ErrorIs(err, sdk.ErrNotFound) { + return sdk.NewErrorFrom(err, "cannot found workflow holder of the environment") + } + env.WorkflowAscodeHolder = wkAscodeHolder + + // FIXME from_repository should never be set if the workflow holder was deleted + if env.WorkflowAscodeHolder == nil { + env.FromRepository = "" + } + } + return service.WriteJSON(w, env, http.StatusOK) } } @@ -194,6 +224,111 @@ func (api *API) deleteEnvironmentHandler() service.Handler { } } +func (api *API) updateAsCodeEnvironmentHandler() service.Handler { + return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + // Get pipeline and action name in URL + vars := mux.Vars(r) + key := vars[permProjectKey] + environmentName := vars["environmentName"] + + branch := FormString(r, "branch") + message := FormString(r, "message") + + if branch == "" || message == "" { + return sdk.NewErrorFrom(sdk.ErrWrongRequest, "missing branch or message data") + } + + var env sdk.Environment + if err := service.UnmarshalBody(r, &env); err != nil { + return err + } + + // check application name pattern + regexp := sdk.NamePatternRegex + if !regexp.MatchString(env.Name) { + return sdk.WrapError(sdk.ErrInvalidApplicationPattern, "Environment name %s do not respect pattern", env.Name) + } + + proj, err := project.Load(api.mustDB(), key, project.LoadOptions.WithClearKeys) + if err != nil { + return err + } + + envDB, err := environment.LoadEnvironmentByName(api.mustDB(), key, environmentName) + if err != nil { + return sdk.WrapError(err, "cannot load environment %s", environmentName) + } + + if envDB.FromRepository == "" { + return sdk.NewErrorFrom(sdk.ErrForbidden, "current environment is not ascode") + } + + wkHolder, err := workflow.LoadByRepo(ctx, api.mustDB(), *proj, envDB.FromRepository, workflow.LoadOptions{ + WithTemplate: true, + }) + if err != nil { + return err + } + if wkHolder.TemplateInstance != nil { + return sdk.NewErrorFrom(sdk.ErrForbidden, "cannot edit an application that was generated by a template") + } + + var rootApp *sdk.Application + if wkHolder.WorkflowData.Node.Context != nil && wkHolder.WorkflowData.Node.Context.ApplicationID != 0 { + rootApp, err = application.LoadByIDWithClearVCSStrategyPassword(api.mustDB(), wkHolder.WorkflowData.Node.Context.ApplicationID) + if err != nil { + return err + } + } + if rootApp == nil { + return sdk.NewErrorFrom(sdk.ErrWrongRequest, "cannot find the root application of the workflow %s that hold the pipeline", wkHolder.Name) + } + + // create keys + for i := range env.Keys { + k := &env.Keys[i] + newKey, err := keys.GenerateKey(k.Name, k.Type) + if err != nil { + return err + } + k.Public = newKey.Public + k.Private = newKey.Private + k.KeyID = newKey.KeyID + } + + u := getAPIConsumer(ctx) + env.ProjectID = proj.ID + envExported, err := environment.ExportEnvironment(api.mustDB(), env, project.EncryptWithBuiltinKey, fmt.Sprintf("env:%d:%s", envDB.ID, branch)) + if err != nil { + return err + } + wp := exportentities.WorkflowComponents{ + Environments: []exportentities.Environment{envExported}, + } + + ope, err := operation.PushOperationUpdate(ctx, api.mustDB(), api.Cache, *proj, wp, rootApp.VCSServer, rootApp.RepositoryFullname, branch, message, rootApp.RepositoryStrategy, u) + if err != nil { + return err + } + + sdk.GoRoutine(context.Background(), fmt.Sprintf("UpdateAsCodeEnvironmentHandler-%s", ope.UUID), func(ctx context.Context) { + ed := ascode.EntityData{ + FromRepo: envDB.FromRepository, + Type: ascode.EnvironmentEvent, + ID: envDB.ID, + Name: envDB.Name, + OperationUUID: ope.UUID, + } + asCodeEvent := ascode.UpdateAsCodeResult(ctx, api.mustDB(), api.Cache, *proj, wkHolder.ID, *rootApp, ed, u) + if asCodeEvent != nil { + event.PublishAsCodeEvent(ctx, proj.Key, *asCodeEvent, u) + } + }, api.PanicDump()) + + return service.WriteJSON(w, ope, http.StatusOK) + } +} + func (api *API) updateEnvironmentHandler() service.Handler { return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error { // Get pipeline and action name in URL diff --git a/engine/api/environment/environment_exporter.go b/engine/api/environment/environment_exporter.go index 2211754ab2..296d3ca1b9 100644 --- a/engine/api/environment/environment_exporter.go +++ b/engine/api/environment/environment_exporter.go @@ -32,11 +32,11 @@ func Export(ctx context.Context, db gorp.SqlExecutor, key string, envName string } env.Keys = keys - return ExportEnvironment(db, *env, encryptFunc) + return ExportEnvironment(db, *env, encryptFunc, fmt.Sprintf("env:%d", env.ID)) } // ExportEnvironment encrypt and export -func ExportEnvironment(db gorp.SqlExecutor, env sdk.Environment, encryptFunc sdk.EncryptFunc) (exportentities.Environment, error) { +func ExportEnvironment(db gorp.SqlExecutor, env sdk.Environment, encryptFunc sdk.EncryptFunc, encryptPrefix string) (exportentities.Environment, error) { var envvars []sdk.EnvironmentVariable for _, v := range env.Variables { switch v.Type { diff --git a/engine/api/environment_ascode_test.go b/engine/api/environment_ascode_test.go new file mode 100644 index 0000000000..0a377b4354 --- /dev/null +++ b/engine/api/environment_ascode_test.go @@ -0,0 +1,254 @@ +package api + +import ( + "context" + "encoding/json" + "github.com/ovh/cds/engine/api/environment" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-gorp/gorp" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + + "github.com/ovh/cds/engine/api/application" + "github.com/ovh/cds/engine/api/pipeline" + "github.com/ovh/cds/engine/api/repositoriesmanager" + "github.com/ovh/cds/engine/api/services" + "github.com/ovh/cds/engine/api/services/mock_services" + "github.com/ovh/cds/engine/api/test" + "github.com/ovh/cds/engine/api/test/assets" + "github.com/ovh/cds/engine/api/workflow" + "github.com/ovh/cds/sdk" +) + +func TestUpdateAsCodeEnvironmentHandler(t *testing.T) { + api, db, _ := newTestAPI(t) + + u, pass := assets.InsertAdminUser(t, db) + + UUID := sdk.UUID() + + svcs, errS := services.LoadAll(context.TODO(), db) + require.NoError(t, errS) + for _, s := range svcs { + _ = services.Delete(db, &s) // nolint + } + + _, _ = assets.InsertService(t, db, t.Name()+"_HOOKS", services.TypeHooks) + _, _ = assets.InsertService(t, db, t.Name()+"_VCS", services.TypeVCS) + _, _ = assets.InsertService(t, db, t.Name()+"_REPO", services.TypeRepositories) + + // Setup a mock for all services called by the API + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + servicesClients := mock_services.NewMockClient(ctrl) + services.NewClient = func(_ gorp.SqlExecutor, _ []sdk.Service) services.Client { + return servicesClients + } + defer func() { + services.NewClient = services.NewDefaultClient + }() + + servicesClients.EXPECT(). + DoJSONRequest(gomock.Any(), "POST", "/task/bulk", gomock.Any(), gomock.Any()). + Return(nil, 201, nil) + + servicesClients.EXPECT(). + DoJSONRequest(gomock.Any(), "GET", "/vcs/github/webhooks", gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn( + func(ctx context.Context, method, path string, in interface{}, out interface{}, _ interface{}) (http.Header, int, error) { + *(out.(*repositoriesmanager.WebhooksInfos)) = repositoriesmanager.WebhooksInfos{ + WebhooksSupported: true, + WebhooksDisabled: false, + Icon: sdk.GitHubIcon, + Events: []string{ + "push", + }, + } + + return nil, 200, nil + }, + ) + servicesClients.EXPECT().DoJSONRequest(gomock.Any(), "GET", "/vcs/github/repos/foo/myrepo/branches", gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, method, path string, in interface{}, out interface{}, _ interface{}) (http.Header, int, error) { + bs := []sdk.VCSBranch{} + b := sdk.VCSBranch{ + DisplayID: "master", + LatestCommit: "aaaaaaa", + } + bs = append(bs, b) + out = bs + return nil, 200, nil + }).Times(1) + + servicesClients.EXPECT(). + DoJSONRequest(gomock.Any(), "POST", "/vcs/github/repos/foo/myrepo/hooks", gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn( + func(ctx context.Context, method, path string, in interface{}, out interface{}, _ interface{}) (http.Header, int, error) { + vcsHooks, _ := in.(*sdk.VCSHook) + vcsHooks.Events = []string{"push"} + vcsHooks.ID = sdk.UUID() + *(out.(*sdk.VCSHook)) = *vcsHooks + return nil, 200, nil + }, + ).Times(1) + + servicesClients.EXPECT(). + DoJSONRequest(gomock.Any(), "GET", "/vcs/github/repos/foo/myrepo", gomock.Any(), gomock.Any(), gomock.Any()).MinTimes(0) + + servicesClients.EXPECT(). + DoMultiPartRequest(gomock.Any(), "POST", "/operations", gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, method, path string, _ interface{}, in interface{}, out interface{}) (int, error) { + ope := new(sdk.Operation) + ope.UUID = UUID + *(out.(*sdk.Operation)) = *ope + return 200, nil + }).Times(1) + + servicesClients.EXPECT(). + DoJSONRequest(gomock.Any(), "GET", "/vcs/github/repos/foo/myrepo/pullrequests?state=open", gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, method, path string, in interface{}, out interface{}, _ interface{}) (http.Header, int, error) { + vcsPRs := []sdk.VCSPullRequest{} + *(out.(*[]sdk.VCSPullRequest)) = vcsPRs + return nil, 200, nil + }).Times(1) + + servicesClients.EXPECT(). + DoJSONRequest(gomock.Any(), "POST", "/vcs/github/repos/foo/myrepo/pullrequests", gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, method, path string, in interface{}, out interface{}, _ interface{}) (http.Header, int, error) { + vcsPR := sdk.VCSPullRequest{} + vcsPR.URL = "myURL" + *(out.(*sdk.VCSPullRequest)) = vcsPR + return nil, 200, nil + }).Times(1) + + servicesClients.EXPECT(). + DoJSONRequest(gomock.Any(), "GET", "/operations/"+UUID, gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn( + func(ctx context.Context, method, path string, in interface{}, out interface{}) (http.Header, int, error) { + ope := new(sdk.Operation) + ope.URL = "https://github.com/fsamin/go-repo.git" + ope.UUID = UUID + ope.Status = sdk.OperationStatusDone + ope.VCSServer = "github" + ope.RepoFullName = "fsamin/go-repo" + ope.RepositoryStrategy.Branch = "master" + ope.Setup.Checkout.Branch = "master" + ope.RepositoryInfo = new(sdk.OperationRepositoryInfo) + ope.RepositoryInfo.Name = "fsamin/go-repo" + ope.RepositoryInfo.DefaultBranch = "master" + ope.RepositoryInfo.FetchURL = "https://github.com/fsamin/go-repo.git" + ope.LoadFiles.Pattern = workflow.WorkflowAsCodePattern + + *(out.(*sdk.Operation)) = *ope + return nil, 200, nil + }, + ).Times(1) + + require.NoError(t, workflow.CreateBuiltinWorkflowHookModels(db)) + + // Create Project + pkey := sdk.RandomString(10) + proj := assets.InsertTestProject(t, db, api.Cache, pkey, pkey) + vcsServer := sdk.ProjectVCSServerLink{ + ProjectID: proj.ID, + Name: "github", + } + vcsServer.Set("token", "foo") + vcsServer.Set("secret", "bar") + require.NoError(t, repositoriesmanager.InsertProjectVCSServerLink(context.TODO(), db, &vcsServer)) + + pip := sdk.Pipeline{ + Name: sdk.RandomString(10), + ProjectID: proj.ID, + FromRepository: "myrepofrom", + } + require.NoError(t, pipeline.InsertPipeline(db, &pip)) + + pip.Stages = []sdk.Stage{ + { + Name: "mystage", + BuildOrder: 1, + Enabled: true, + }, + } + + app := sdk.Application{ + Name: sdk.RandomString(10), + ProjectID: proj.ID, + RepositoryFullname: "foo/myrepo", + VCSServer: "github", + FromRepository: "myrepofrom", + } + require.NoError(t, application.Insert(db, *proj, &app)) + require.NoError(t, repositoriesmanager.InsertForApplication(db, &app)) + + env := sdk.Environment{ + Name: sdk.RandomString(10), + ProjectID: proj.ID, + FromRepository: "myrepofrom", + } + require.NoError(t, environment.InsertEnvironment(db, &env)) + + repoModel, err := workflow.LoadHookModelByName(db, sdk.RepositoryWebHookModelName) + require.NoError(t, err) + + wk := initWorkflow(t, db, proj, &app, &pip, repoModel) + wk.FromRepository = "myrepofrom" + require.NoError(t, workflow.Insert(context.Background(), db, api.Cache, *proj, wk)) + + uri := api.Router.GetRoute("PUT", api.updateAsCodeEnvironmentHandler, map[string]string{ + "permProjectKey": proj.Key, + "environmentName": env.Name, + }) + req := assets.NewJWTAuthentifiedRequest(t, pass, "PUT", uri, env) + q := req.URL.Query() + q.Set("branch", "master") + q.Set("message", "my message") + req.URL.RawQuery = q.Encode() + + // Do the request + wr := httptest.NewRecorder() + api.Router.Mux.ServeHTTP(wr, req) + require.Equal(t, 200, wr.Code) + myOpe := new(sdk.Operation) + test.NoError(t, json.Unmarshal(wr.Body.Bytes(), myOpe)) + require.NotEmpty(t, myOpe.UUID) + + cpt := 0 + for { + if cpt >= 10 { + t.Fail() + return + } + + // Get operation + uriGET := api.Router.GetRoute("GET", api.getWorkflowAsCodeHandler, map[string]string{ + "key": proj.Key, + "permWorkflowName": wk.Name, + "uuid": myOpe.UUID, + }) + reqGET, err := http.NewRequest("GET", uriGET, nil) + test.NoError(t, err) + assets.AuthentifyRequest(t, reqGET, u, pass) + wrGet := httptest.NewRecorder() + api.Router.Mux.ServeHTTP(wrGet, reqGET) + require.Equal(t, 200, wrGet.Code) + myOpeGet := new(sdk.Operation) + err = json.Unmarshal(wrGet.Body.Bytes(), myOpeGet) + require.NoError(t, err) + + if myOpeGet.Status < sdk.OperationStatusDone { + cpt++ + time.Sleep(1 * time.Second) + continue + } + test.NoError(t, json.Unmarshal(wrGet.Body.Bytes(), myOpeGet)) + require.Equal(t, "myURL", myOpeGet.Setup.Push.PRLink) + break + } +} diff --git a/engine/api/pipeline.go b/engine/api/pipeline.go index 315fc4b507..949968046e 100644 --- a/engine/api/pipeline.go +++ b/engine/api/pipeline.go @@ -312,21 +312,21 @@ func (api *API) getPipelineHandler() service.Handler { withWorkflows := FormBool(r, "withWorkflows") withAsCodeEvent := FormBool(r, "withAsCodeEvents") - proj, err := project.Load(api.mustDB(), projectKey, - project.LoadOptions.WithApplicationWithDeploymentStrategies, - project.LoadOptions.WithPipelines, - project.LoadOptions.WithEnvironments, - project.LoadOptions.WithIntegrations) - if err != nil { - return err - } - p, err := pipeline.LoadPipeline(ctx, api.mustDB(), projectKey, pipelineName, true) if err != nil { return sdk.WrapError(err, "cannot load pipeline %s", pipelineName) } if p.FromRepository != "" { + proj, err := project.Load(api.mustDB(), projectKey, + project.LoadOptions.WithApplicationWithDeploymentStrategies, + project.LoadOptions.WithPipelines, + project.LoadOptions.WithEnvironments, + project.LoadOptions.WithIntegrations) + if err != nil { + return err + } + wkAscodeHolder, err := workflow.LoadByRepo(ctx, api.mustDB(), *proj, p.FromRepository, workflow.LoadOptions{ WithTemplate: true, }) diff --git a/engine/api/workflow/workflow_exporter.go b/engine/api/workflow/workflow_exporter.go index 032fe92bf6..46d79bacf1 100644 --- a/engine/api/workflow/workflow_exporter.go +++ b/engine/api/workflow/workflow_exporter.go @@ -124,7 +124,7 @@ func Pull(ctx context.Context, db gorp.SqlExecutor, cache cache.Store, proj sdk. if e.FromRepository != wf.FromRepository { // don't export if coming from an other repository continue } - env, err := environment.ExportEnvironment(db, e, encryptFunc) + env, err := environment.ExportEnvironment(db, e, encryptFunc, fmt.Sprintf("env:%d", e.ID)) if err != nil { return wp, sdk.WrapError(err, "unable to export env %s", e.Name) } diff --git a/sdk/environment.go b/sdk/environment.go index ca8779ed13..c7cd4a59c3 100644 --- a/sdk/environment.go +++ b/sdk/environment.go @@ -7,16 +7,17 @@ import ( // Environment represent a deployment environment type Environment struct { - ID int64 `json:"id" yaml:"-"` - Name string `json:"name" yaml:"name" cli:"name,key"` - Variables []EnvironmentVariable `json:"variables,omitempty" yaml:"variables"` - ProjectID int64 `json:"-" yaml:"-"` - ProjectKey string `json:"project_key" yaml:"-"` - Created time.Time `json:"created"` - LastModified time.Time `json:"last_modified"` - Keys []EnvironmentKey `json:"keys"` - Usage *Usage `json:"usage,omitempty"` - FromRepository string `json:"from_repository,omitempty"` + ID int64 `json:"id" yaml:"-"` + Name string `json:"name" yaml:"name" cli:"name,key"` + Variables []EnvironmentVariable `json:"variables,omitempty" yaml:"variables"` + ProjectID int64 `json:"-" yaml:"-"` + ProjectKey string `json:"project_key" yaml:"-"` + Created time.Time `json:"created"` + LastModified time.Time `json:"last_modified"` + Keys []EnvironmentKey `json:"keys"` + Usage *Usage `json:"usage,omitempty"` + FromRepository string `json:"from_repository,omitempty"` + WorkflowAscodeHolder *Workflow `json:"workflow_ascode_holder,omitempty" cli:"-"` } // UnmarshalJSON custom for last modified. diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 6b6e092275..eba1a7efc6 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -7,23 +7,20 @@ import { ActivatedRoute, NavigationEnd, NavigationStart, ResolveEnd, ResolveStar import { TranslateService } from '@ngx-translate/core'; import { Store } from '@ngxs/store'; import { EventService } from 'app/event.service'; -import { WorkflowNodeRun } from 'app/model/workflow.run.model'; import { GetCDSStatus } from 'app/store/cds.action'; import { CDSState } from 'app/store/cds.state'; import { Observable } from 'rxjs'; import { WebSocketSubject } from 'rxjs/internal-compatibility'; -import { bufferTime, filter, map, mergeMap } from 'rxjs/operators'; +import { filter, map, mergeMap } from 'rxjs/operators'; import { Subscription } from 'rxjs/Subscription'; import * as format from 'string-format-obj'; import { AppService } from './app.service'; -import { Event, EventType } from './model/event.model'; import { AuthentifiedUser } from './model/user.model'; import { LanguageStore } from './service/language/language.store'; import { NotificationService } from './service/notification/notification.service'; import { ThemeStore } from './service/theme/theme.store'; import { AutoUnsubscribe } from './shared/decorator/autoUnsubscribe'; import { ToastService } from './shared/toast/ToastService'; -import { CDSSharedWorker } from './shared/worker/shared.worker'; import { CDSWebWorker } from './shared/worker/web.worker'; import { CDSWorker } from './shared/worker/worker'; import { AuthenticationState } from './store/authentication.state'; diff --git a/ui/src/app/app.service.ts b/ui/src/app/app.service.ts index db629fcad1..ce23399213 100644 --- a/ui/src/app/app.service.ts +++ b/ui/src/app/app.service.ts @@ -85,7 +85,11 @@ export class AppService { event.type_event === EventType.WORKFLOW_ADD || event.type_event === EventType.WORKFLOW_UPDATE || event.type_event === EventType.WORKFLOW_DELETE) { this.updateProjectCache(event); - this._navbarService.refreshData(); + + if (event.type_event === EventType.APPLICATION_UPDATE || event.type_event === EventType.WORKFLOW_UPDATE) { + this._navbarService.refreshData(); + } + } if (event.type_event.indexOf(EventType.APPLICATION_PREFIX) === 0) { this.updateApplicationCache(event); @@ -177,7 +181,8 @@ export class AppService { opts.push(new LoadOpts('withLabels', 'labels')); } - if (event.type_event.indexOf('Variable') === -1 && event.type_event.indexOf('Parameter') === -1) { + if (event.type_event.indexOf('Variable') === -1 && event.type_event.indexOf('Parameter') === -1 + && event.type_event.indexOf(EventType.ENVIRONMENT_PREFIX) === -1) { this._store.dispatch(new projectActions.ResyncProject({ projectKey: projectInCache.key, opts })); } }); diff --git a/ui/src/app/event.service.ts b/ui/src/app/event.service.ts index 1c9aa7219a..ed07474df3 100644 --- a/ui/src/app/event.service.ts +++ b/ui/src/app/event.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { AppService } from 'app/app.service'; -import { AuthentifiedUser } from 'app/model/user.model'; import { WebSocketEvent, WebSocketMessage } from 'app/model/websocket.model'; import { ToastService } from 'app/shared/toast/ToastService'; import { WebSocketSubject } from 'rxjs/internal-compatibility'; diff --git a/ui/src/app/model/environment.model.ts b/ui/src/app/model/environment.model.ts index 1ca1fda99b..a1734923ef 100644 --- a/ui/src/app/model/environment.model.ts +++ b/ui/src/app/model/environment.model.ts @@ -1,3 +1,4 @@ +import { Workflow } from 'app/model/workflow.model'; import { Key } from './keys.model'; import { Usage } from './usage.model'; import { Variable } from './variable.model'; @@ -13,4 +14,6 @@ export class Environment { from_repository: string; mute: boolean; + editModeChanged: boolean; + workflow_ascode_holder: Workflow; } diff --git a/ui/src/app/service/application/application.service.ts b/ui/src/app/service/application/application.service.ts index 6af028c1b6..8751eebfc3 100644 --- a/ui/src/app/service/application/application.service.ts +++ b/ui/src/app/service/application/application.service.ts @@ -4,7 +4,6 @@ import { Injectable } from '@angular/core'; import { Application, Vulnerability } from 'app/model/application.model'; import { Key } from 'app/model/keys.model'; import { Operation } from 'app/model/operation.model'; -import { Pipeline } from 'app/model/pipeline.model'; import { Observable } from 'rxjs'; @Injectable() diff --git a/ui/src/app/service/environment/environment.service.ts b/ui/src/app/service/environment/environment.service.ts index 46b417a9ba..c11b12afed 100644 --- a/ui/src/app/service/environment/environment.service.ts +++ b/ui/src/app/service/environment/environment.service.ts @@ -1,6 +1,7 @@ import {HttpClient, HttpParams} from '@angular/common/http'; import {Injectable} from '@angular/core'; import {Environment} from 'app/model/environment.model'; +import { Operation } from 'app/model/operation.model'; import {Usage} from 'app/model/usage.model'; import {Observable} from 'rxjs'; /** @@ -31,4 +32,18 @@ export class EnvironmentService { getUsage(key: string, envName: string): Observable { return this._http.get('/project/' + key + '/environment/' + envName + '/usage'); } + + /** + * Update environment as code + * @param key Project key + * @param environment Environment to update + * @param branch Branch name to create the PR + * @param message Message of the commit + */ + updateAsCode(key: string, oldEnvName: string, environment: Environment, branch, message: string): Observable { + let params = new HttpParams(); + params = params.append('branch', branch); + params = params.append('message', message) + return this._http.put(`/project/${key}/environment/${oldEnvName}/ascode`, environment, { params }); + } } diff --git a/ui/src/app/shared/ascode/save-form/ascode.save-form.component.ts b/ui/src/app/shared/ascode/save-form/ascode.save-form.component.ts index a09ce50786..86ad37e6d8 100644 --- a/ui/src/app/shared/ascode/save-form/ascode.save-form.component.ts +++ b/ui/src/app/shared/ascode/save-form/ascode.save-form.component.ts @@ -34,8 +34,6 @@ export class AsCodeSaveFormComponent implements OnInit { ) { } ngOnInit() { - this.paramChange.emit(new ParamData()); - if (!this.workflow) { return; } @@ -58,24 +56,10 @@ export class AsCodeSaveFormComponent implements OnInit { }); } - optionsFilter = (opts: Array, query: string): Array => { - this.selectedBranch = query; - let result = Array(); - opts.forEach(o => { - if (o.indexOf(query) > -1) { - result.push(o); - } - }); - if (result.indexOf(query) === -1) { - result.push(query); - } - return result; - }; - changeParam(): void { this.paramChange.emit({ branch_name: this.selectedBranch, commit_message: this.commitMessage - }) + }); } } diff --git a/ui/src/app/shared/ascode/save-form/ascode.save-form.html b/ui/src/app/shared/ascode/save-form/ascode.save-form.html index 4b94a5959a..5672bf7418 100644 --- a/ui/src/app/shared/ascode/save-form/ascode.save-form.html +++ b/ui/src/app/shared/ascode/save-form/ascode.save-form.html @@ -1,22 +1,13 @@
-
+
- - - - -
-
- - +
- +
diff --git a/ui/src/app/shared/ascode/save-modal/ascode.save-modal.component.ts b/ui/src/app/shared/ascode/save-modal/ascode.save-modal.component.ts index 90ff98d66a..4e2239ede9 100644 --- a/ui/src/app/shared/ascode/save-modal/ascode.save-modal.component.ts +++ b/ui/src/app/shared/ascode/save-modal/ascode.save-modal.component.ts @@ -2,11 +2,13 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, ViewChild import { TranslateService } from '@ngx-translate/core'; import { ModalTemplate, SuiActiveModal, SuiModalService, TemplateModalConfig } from '@richardlt/ng2-semantic-ui'; import { Application } from 'app/model/application.model'; +import { Environment } from 'app/model/environment.model'; import { Operation } from 'app/model/operation.model'; import { Pipeline } from 'app/model/pipeline.model'; import { Project } from 'app/model/project.model'; import { Workflow } from 'app/model/workflow.model'; import { ApplicationService } from 'app/service/application/application.service'; +import { EnvironmentService } from 'app/service/environment/environment.service'; import { PipelineService } from 'app/service/pipeline/pipeline.service'; import { WorkflowService } from 'app/service/workflow/workflow.service'; import { AutoUnsubscribe } from 'app/shared/decorator/autoUnsubscribe'; @@ -38,6 +40,8 @@ export class AsCodeSaveModalComponent { pollingOperationSub: Subscription; parameters: ParamData; repositoryFullname: string; + canSave = false; + displayCloseButton = false; constructor( private _modalService: SuiModalService, @@ -46,7 +50,8 @@ export class AsCodeSaveModalComponent { private _translate: TranslateService, private _workflowService: WorkflowService, private _pipService: PipelineService, - private _appService: ApplicationService + private _appService: ApplicationService, + private _envService: EnvironmentService ) { } show(data: any, type: string) { @@ -69,6 +74,7 @@ export class AsCodeSaveModalComponent { } close() { + delete this.parameters; this.modal.approve(true); } @@ -101,6 +107,15 @@ export class AsCodeSaveModalComponent { this.startPollingOperation((this.dataToSave).workflow_ascode_holder.name); }); break; + case 'environment': + this.loading = true; + this._cd.markForCheck(); + this._envService.updateAsCode(this.project.key, this.name, this.dataToSave, + this.parameters.branch_name, this.parameters.commit_message).subscribe(o => { + this.asCodeOperation = o; + this.startPollingOperation((this.dataToSave).workflow_ascode_holder.name); + }); + break; default: this._toast.error('', this._translate.instant('ascode_error_unknown_type')) } @@ -116,10 +131,17 @@ export class AsCodeSaveModalComponent { })) .subscribe(o => { this.asCodeOperation = o; + this.displayCloseButton = true; }); } onParamChange(param: ParamData): void { this.parameters = param; + this.canSave = !this.isEmpty(this.parameters.commit_message) && !this.isEmpty(this.parameters.branch_name); + this._cd.markForCheck(); + } + + isEmpty(str: string): boolean { + return (!str || str.length === 0); } } diff --git a/ui/src/app/shared/ascode/save-modal/ascode.save-modal.html b/ui/src/app/shared/ascode/save-modal/ascode.save-modal.html index ab64a31f3a..b9d8032254 100644 --- a/ui/src/app/shared/ascode/save-modal/ascode.save-modal.html +++ b/ui/src/app/shared/ascode/save-modal/ascode.save-modal.html @@ -9,10 +9,19 @@
-
- {{'common_cancel' | translate}} -
-
- {{'btn_save' | translate}}
+ +
+ {{'common_cancel' | translate}} +
+
+ {{'btn_save' | translate}}
+
+ +
+ {{'common_close' | translate}} +
+
diff --git a/ui/src/app/shared/variable/list/variable.component.ts b/ui/src/app/shared/variable/list/variable.component.ts index f230f4c215..7267698506 100644 --- a/ui/src/app/shared/variable/list/variable.component.ts +++ b/ui/src/app/shared/variable/list/variable.component.ts @@ -7,8 +7,6 @@ import { Output, ViewChild } from '@angular/core'; -import { Application } from 'app/model/application.model'; -import { Environment } from 'app/model/environment.model'; import { Project } from 'app/model/project.model'; import { Variable, VariableAudit } from 'app/model/variable.model'; import { ApplicationAuditService } from 'app/service/application/application.audit.service'; diff --git a/ui/src/app/shared/workflow/wizard/node-add/node.wizard.component.ts b/ui/src/app/shared/workflow/wizard/node-add/node.wizard.component.ts index d681ad1a60..c4cacfaabf 100644 --- a/ui/src/app/shared/workflow/wizard/node-add/node.wizard.component.ts +++ b/ui/src/app/shared/workflow/wizard/node-add/node.wizard.component.ts @@ -18,9 +18,9 @@ import { ApplicationService } from 'app/service/application/application.service' import { ToastService } from 'app/shared/toast/ToastService'; import { AddApplication } from 'app/store/applications.action'; import { ApplicationsState, ApplicationStateModel } from 'app/store/applications.state'; +import { AddEnvironment } from 'app/store/environment.action'; import { AddPipeline } from 'app/store/pipelines.action'; import { PipelinesState, PipelinesStateModel } from 'app/store/pipelines.state'; -import { AddEnvironmentInProject } from 'app/store/project.action'; import { ProjectState, ProjectStateModel } from 'app/store/project.state'; import cloneDeep from 'lodash-es/cloneDeep'; import { Observable, of as observableOf } from 'rxjs'; @@ -285,7 +285,7 @@ export class WorkflowNodeAddWizardComponent implements OnInit { createEnvironment(): Observable { this.loadingCreateEnvironment = true; - return this.store.dispatch(new AddEnvironmentInProject({ + return this.store.dispatch(new AddEnvironment({ projectKey: this.project.key, environment: this.newEnvironment })).pipe( diff --git a/ui/src/app/store/environment.action.ts b/ui/src/app/store/environment.action.ts new file mode 100644 index 0000000000..e0c6e527cf --- /dev/null +++ b/ui/src/app/store/environment.action.ts @@ -0,0 +1,77 @@ +import { Environment } from 'app/model/environment.model'; +import { Key } from 'app/model/keys.model'; +import { Variable } from 'app/model/variable.model'; + + +export class AddEnvironment { + static readonly type = '[Environment] Add Environment'; + constructor(public payload: { projectKey: string, environment: Environment }) { } +} + +export class CloneEnvironment { + static readonly type = '[Environment] Clone Environment'; + constructor(public payload: { projectKey: string, cloneName: string, environment: Environment }) { } +} + +export class UpdateEnvironment { + static readonly type = '[Environment] Update environment'; + constructor(public payload: { projectKey: string, environmentName: string, changes: Environment }) { } +} +export class DeleteEnvironment { + static readonly type = '[Environment] Delete Environment'; + constructor(public payload: { projectKey: string, environment: Environment }) { } +} + +// LOAD + +export class FetchEnvironment { + static readonly type = '[Environment] Fetch Single Environment'; + constructor(public payload: { projectKey: string, envName: string }) { } +} + +export class LoadEnvironment { + static readonly type = '[Environment] Load Environment'; + constructor(public payload: {projectKey: string, env: Environment}) { } +} + +export class ResyncEnvironment { + static readonly type = '[Environment] Resync Single Environment'; + constructor(public payload: { projectKey: string, envName: string }) { } +} + + +// VARIABLE + +export class AddEnvironmentVariable { + static readonly type = '[Environment] Add Environment Variable'; + constructor(public payload: { projectKey: string, environmentName: string, variable: Variable }) { } +} +export class UpdateEnvironmentVariable { + static readonly type = '[Environment] Update environment variable'; + constructor(public payload: { projectKey: string, environmentName: string, variableName: string, changes: Variable }) { } +} +export class DeleteEnvironmentVariable { + static readonly type = '[Environment] Delete Environment Variable '; + constructor(public payload: { projectKey: string, environmentName: string, variable: Variable }) { } +} + +// KEY + +export class AddEnvironmentKey { + static readonly type = '[Environment] Add Environment Key'; + constructor(public payload: { projectKey: string, envName: string, key: Key }) { } +} + +export class DeleteEnvironmentKey { + static readonly type = '[Environment] Delete Environment Key'; + constructor(public payload: { projectKey: string, envName: string, key: Key }) { } +} + +// Clean +export class CleanEnvironmentState { + static readonly type = '[Environment] Clean state'; + constructor() { } +} + + + diff --git a/ui/src/app/store/environment.state.spec.ts b/ui/src/app/store/environment.state.spec.ts new file mode 100644 index 0000000000..8dd4ddc2a4 --- /dev/null +++ b/ui/src/app/store/environment.state.spec.ts @@ -0,0 +1,276 @@ +import { HttpRequest } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { async, TestBed } from '@angular/core/testing'; +import { NgxsModule, Store } from '@ngxs/store'; +import { Environment } from 'app/model/environment.model'; +import { Project } from 'app/model/project.model'; +import { Variable } from 'app/model/variable.model'; +import { NavbarService } from 'app/service/navbar/navbar.service'; +import { ProjectService } from 'app/service/project/project.service'; +import { ProjectStore } from 'app/service/project/project.store'; +import { WorkflowRunService } from 'app/service/workflow/run/workflow.run.service'; +import { WorkflowService } from 'app/service/workflow/workflow.service'; +import { + AddEnvironment, + FetchEnvironment, + UpdateEnvironment, + AddEnvironmentVariable, + LoadEnvironment, UpdateEnvironmentVariable, DeleteEnvironmentVariable +} from 'app/store/environment.action'; +import { EnvironmentState, EnvironmentStateModel } from 'app/store/environment.state'; +import { cloneDeep } from 'lodash-es'; +import { ApplicationsState } from './applications.state'; +import { PipelinesState } from './pipelines.state'; +import * as ProjectAction from './project.action'; +import { ProjectState, ProjectStateModel } from './project.state'; +import { WorkflowState } from './workflow.state'; +import { PipelineService } from 'app/service/pipeline/pipeline.service'; +import { EnvironmentService } from 'app/service/environment/environment.service'; +import { ApplicationService } from 'app/service/application/application.service'; +import { RouterService } from 'app/service/router/router.service'; +import { RouterTestingModule } from '@angular/router/testing'; + +describe('Environment', () => { + let store: Store; + let http: HttpTestingController; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + providers: [NavbarService, WorkflowService, WorkflowRunService, ProjectStore, RouterService, + ProjectService, PipelineService, EnvironmentService, ApplicationService, EnvironmentService], + imports: [ + HttpClientTestingModule, RouterTestingModule.withRoutes([]), + NgxsModule.forRoot([ProjectState, ApplicationsState, PipelinesState, WorkflowState, EnvironmentState]) + ], + }).compileComponents(); + + store = TestBed.inject(Store); + http = TestBed.inject(HttpTestingController); + })); + + // ------- Environment --------- // + it('add environment in project', async(() => { + let project = new Project(); + project.name = 'proj1'; + project.key = 'test1'; + store.dispatch(new ProjectAction.AddProject(project)); + http.expectOne(((req: HttpRequest) => { + return req.url === '/project'; + })).flush({ + name: 'proj1', + key: 'test1', + }); + + let env = new Environment(); + env.name = 'prod'; + store.dispatch(new AddEnvironment({ projectKey: project.key, environment: env })); + http.expectOne(((req: HttpRequest) => { + return req.url === '/project/test1/environment'; + })).flush({ + ...project, + environments: [env] + }); + + store.selectOnce(EnvironmentState).subscribe((state: EnvironmentStateModel) => { + expect(state.currentProjectKey).toEqual('test1'); + expect(state.environment).toBeTruthy(); + expect(state.environment.name).toEqual('prod'); + }); + })); + + it('fetch environment in project', async(() => { + const http = TestBed.inject(HttpTestingController); + let project = new Project(); + project.name = 'proj1'; + project.key = 'test1'; + store.dispatch(new ProjectAction.AddProject(project)); + http.expectOne(((req: HttpRequest) => { + return req.url === '/project'; + })).flush({ + name: 'proj1', + key: 'test1', + }); + + let env = new Environment(); + env.name = 'prod'; + store.dispatch(new FetchEnvironment({ projectKey: project.key, envName: env.name })); + http.expectOne(((req: HttpRequest) => { + return req.url === '/project/test1/environment/prod'; + })).flush(env); + + store.selectOnce(EnvironmentState).subscribe((state: EnvironmentStateModel) => { + expect(state.currentProjectKey).toEqual('test1'); + expect(state.environment).toBeTruthy(); + expect(state.environment.name).toEqual('prod'); + }); + })); + + it('update environment in project', async(() => { + let project = new Project(); + project.name = 'proj1'; + project.key = 'test1'; + let env = new Environment(); + env.name = 'prod'; + + store.dispatch(new ProjectAction.AddProject(project)); + http.expectOne(((req: HttpRequest) => { + return req.url === '/project'; + })).flush({ + name: 'proj1', + key: 'test1', + environments: [env] + }); + + store.dispatch(new LoadEnvironment({projectKey: project.key, env: env})) + + env.name = 'dev'; + store.dispatch(new UpdateEnvironment({ + projectKey: project.key, + environmentName: 'prod', + changes: env + })); + http.expectOne(((req: HttpRequest) => { + return req.url === '/project/test1/environment/prod'; + })).flush({ + ...project, + environments: [env] + }); + + store.selectOnce(EnvironmentState).subscribe((state: EnvironmentStateModel) => { + expect(state.currentProjectKey).toEqual('test1'); + expect(state.environment).toBeTruthy(); + expect(state.environment.name).toEqual('dev'); + }); + })); + + it('add environment variable in project', async(() => { + let project = new Project(); + project.name = 'proj1'; + project.key = 'test1'; + store.dispatch(new ProjectAction.AddProject(project)); + http.expectOne(((req: HttpRequest) => { + return req.url === '/project'; + })).flush({ + name: 'proj1', + key: 'test1', + environments: [{ name: 'prod' }] + }); + + let env = new Environment(); + env.name = 'prod'; + + store.dispatch(new LoadEnvironment({projectKey: project.key, env: cloneDeep(env)})); + + let variable = new Variable(); + variable.name = 'testvar'; + variable.type = 'string'; + variable.value = 'myvalue'; + store.dispatch(new AddEnvironmentVariable({ + projectKey: project.key, + environmentName: env.name, + variable + })); + http.expectOne(((req: HttpRequest) => { + return req.url === '/project/test1/environment/prod/variable/testvar'; + })).flush(variable); + + store.selectOnce(EnvironmentState).subscribe((state: EnvironmentStateModel) => { + expect(state.currentProjectKey).toEqual('test1'); + expect(state.environment).toBeTruthy(); + expect(state.environment.name).toEqual('prod'); + expect(state.environment.variables).toBeTruthy(); + expect(state.environment.variables.length).toEqual(1); + expect(state.environment.variables[0].name).toEqual('testvar'); + }); + })); + + it('update environment variable in project', async(() => { + let project = new Project(); + project.name = 'proj1'; + project.key = 'test1'; + let env = new Environment(); + env.name = 'prod'; + let variable = new Variable(); + variable.name = 'testvar'; + variable.type = 'string'; + variable.value = 'myvalue'; + env.variables = [variable]; + + store.dispatch(new ProjectAction.AddProject(project)); + http.expectOne(((req: HttpRequest) => { + return req.url === '/project'; + })).flush({ + name: 'proj1', + key: 'test1', + environments: [env] + }); + + store.dispatch(new LoadEnvironment({projectKey: project.key, env: env})); + + variable.name = 'testvarbis'; + store.dispatch(new UpdateEnvironmentVariable({ + projectKey: project.key, + environmentName: env.name, + variableName: 'testvar', + changes: variable + })); + http.expectOne(((req: HttpRequest) => { + return req.url === '/project/test1/environment/prod/variable/testvar'; + })).flush({ + ...project, + environments: [Object.assign({}, env, { variables: [variable] })] + }); + + store.selectOnce(EnvironmentState).subscribe((state: EnvironmentStateModel) => { + expect(state.currentProjectKey).toEqual('test1'); + expect(state.environment).toBeTruthy(); + expect(state.environment.variables).toBeTruthy(); + expect(state.environment.variables.length).toEqual(1); + expect(state.environment.variables[0].name).toEqual('testvarbis'); + }); + })); + + it('delete environment variable in project', async(() => { + let project = new Project(); + project.name = 'proj1'; + project.key = 'test1'; + let env = new Environment(); + env.name = 'prod'; + let variable = new Variable(); + variable.name = 'testvar'; + variable.type = 'string'; + variable.value = 'myvalue'; + env.variables = [variable]; + + store.dispatch(new ProjectAction.AddProject(project)); + http.expectOne(((req: HttpRequest) => { + return req.url === '/project'; + })).flush({ + name: 'proj1', + key: 'test1', + environments: [env] + }); + + store.dispatch(new LoadEnvironment({projectKey: project.key, env: env})); + + store.dispatch(new DeleteEnvironmentVariable({ + projectKey: project.key, + environmentName: env.name, + variable + })); + http.expectOne(((req: HttpRequest) => { + return req.url === '/project/test1/environment/prod/variable/testvar'; + })).flush({ + ...project, + environments: [Object.assign({}, env, { variables: [] })] + }); + + store.selectOnce(EnvironmentState).subscribe((state: EnvironmentStateModel) => { + expect(state.currentProjectKey).toEqual('test1'); + expect(state.environment).toBeTruthy(); + expect(state.environment.variables).toBeTruthy(); + expect(state.environment.variables.length).toEqual(0); + }); + })); + +}); diff --git a/ui/src/app/store/environment.state.ts b/ui/src/app/store/environment.state.ts new file mode 100644 index 0000000000..2b8f190d5f --- /dev/null +++ b/ui/src/app/store/environment.state.ts @@ -0,0 +1,312 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Action, createSelector, State, StateContext } from '@ngxs/store'; +import { Environment } from 'app/model/environment.model'; +import { Key } from 'app/model/keys.model'; +import { Project } from 'app/model/project.model'; +import { Variable } from 'app/model/variable.model'; +import { EnvironmentService } from 'app/service/environment/environment.service'; +import { cloneDeep } from 'lodash-es'; +import { tap } from 'rxjs/operators'; +import * as ActionEnvironment from './environment.action'; + +export class EnvironmentStateModel { + public environment: Environment; + public editEnvironment: Environment; + public currentProjectKey: string; + public loading: boolean; + public editMode: boolean; +} + +export function getInitialEnvironmentState(): EnvironmentStateModel { + return { + environment: null, + editEnvironment: null, + currentProjectKey: null, + loading: true, + editMode: false + }; +} + +@State({ + name: 'environment', + defaults: getInitialEnvironmentState() +}) +@Injectable() +export class EnvironmentState { + + constructor(private _http: HttpClient, private _envService: EnvironmentService) { } + + static currentState() { + return createSelector( + [EnvironmentState], + (state: EnvironmentStateModel) => state + ); + } + + @Action(ActionEnvironment.AddEnvironment) + addEnvironment(ctx: StateContext, action: ActionEnvironment.AddEnvironment) { + return this._http.post('/project/' + action.payload.projectKey + '/environment', action.payload.environment) + .pipe(tap((project: Project) => { + const state = ctx.getState(); + ctx.setState({ + ...state, + currentProjectKey: action.payload.projectKey, + environment: project.environments.find(e => e.name === action.payload.environment.name), + loading: false, + editEnvironment: null, + editMode: false + }); + })); + } + + @Action(ActionEnvironment.CloneEnvironment) + cloneEnvironment(ctx: StateContext, action: ActionEnvironment.CloneEnvironment) { + return this._http.post( + '/project/' + action.payload.projectKey + '/environment/' + + action.payload.environment.name + '/clone/' + action.payload.cloneName, + null + ).pipe(tap((project: Project) => { + const state = ctx.getState(); + ctx.setState({ + ...state, + currentProjectKey: action.payload.projectKey, + environment: project.environments.find(e => e.name === action.payload.environment.name), + loading: false, + editEnvironment: null, + editMode: false + }); + })); + } + + @Action(ActionEnvironment.UpdateEnvironment) + update(ctx: StateContext, action: ActionEnvironment.UpdateEnvironment) { + const stateEditMode = ctx.getState(); + if (stateEditMode.editMode) { + let envToUpdate = cloneDeep(stateEditMode.editEnvironment); + envToUpdate.name = action.payload.changes.name; + envToUpdate.editModeChanged = true; + return ctx.setState({ + ...stateEditMode, + editEnvironment: envToUpdate, + }); + } + + + return this._http.put( + `/project/${action.payload.projectKey}/environment/${action.payload.environmentName}`, + action.payload.changes + ).pipe(tap((p: Project) => { + const state = ctx.getState(); + ctx.setState({ + ...state, + environment: p.environments.find(e => e.name === action.payload.changes.name), + editMode: false, + editEnvironment: null, + loading: false + }); + })); + } + + @Action(ActionEnvironment.DeleteEnvironment) + deleteEnvironment(ctx: StateContext, action: ActionEnvironment.DeleteEnvironment) { + return this._http.delete('/project/' + action.payload.projectKey + '/environment/' + action.payload.environment.name); + } + + @Action(ActionEnvironment.FetchEnvironment) + fetchEnvironment(ctx: StateContext, action: ActionEnvironment.FetchEnvironment) { + const state = ctx.getState(); + if (state.environment && state.environment.name === action.payload.envName + && state.currentProjectKey === action.payload.projectKey) { + return ctx.dispatch(new ActionEnvironment.LoadEnvironment({projectKey: action.payload.projectKey, env: state.environment})); + } + return ctx.dispatch(new ActionEnvironment.ResyncEnvironment({ ...action.payload })); + } + + @Action(ActionEnvironment.LoadEnvironment) + load(ctx: StateContext, action: ActionEnvironment.LoadEnvironment) { + const state = ctx.getState(); + let editMode = false; + if (action.payload.env.from_repository) { + editMode = true; + } + ctx.setState({ + ...state, + currentProjectKey: action.payload.projectKey, + environment: action.payload.env, + editEnvironment: cloneDeep(action.payload.env), + editMode: editMode, + loading: false, + }); + } + + @Action(ActionEnvironment.ResyncEnvironment) + resync(ctx: StateContext, action: ActionEnvironment.ResyncEnvironment) { + return this._envService.getEnvironment(action.payload.projectKey, action.payload.envName) + .pipe(tap((environment: Environment) => { + return ctx.dispatch(new ActionEnvironment.LoadEnvironment({projectKey: action.payload.projectKey, env: environment})); + })); + } + + // VARIABLES + @Action(ActionEnvironment.AddEnvironmentVariable) + addEnvironmentVariable(ctx: StateContext, action: ActionEnvironment.AddEnvironmentVariable) { + const stateEditMode = ctx.getState(); + if (stateEditMode.editMode) { + let envToUpdate = cloneDeep(stateEditMode.editEnvironment); + if (!envToUpdate.variables) { + envToUpdate.variables = new Array(); + } + delete action.payload.variable.updating; + delete action.payload.variable.hasChanged; + envToUpdate.variables.push(action.payload.variable); + envToUpdate.editModeChanged = true; + return ctx.setState({ + ...stateEditMode, + editEnvironment: envToUpdate, + }); + } + return this._http.post( + '/project/' + action.payload.projectKey + '/environment/' + + action.payload.environmentName + '/variable/' + action.payload.variable.name, + action.payload.variable + ).pipe(tap((v: Variable) => { + const state = ctx.getState(); + let env = cloneDeep(state.environment) + if (!env.variables) { + env.variables = new Array(); + } + env.variables.push(v); + ctx.setState({ + ...state, + environment: env, + }); + })); + } + + @Action(ActionEnvironment.DeleteEnvironmentVariable) + deleteEnvironmentVariable(ctx: StateContext, action: ActionEnvironment.DeleteEnvironmentVariable) { + const stateEditMode = ctx.getState(); + if (stateEditMode.editMode) { + let envToUpdate = cloneDeep(stateEditMode.editEnvironment); + envToUpdate.variables = envToUpdate.variables.filter(e => e.name !== action.payload.variable.name); + envToUpdate.editModeChanged = true; + return ctx.setState({ + ...stateEditMode, + editEnvironment: envToUpdate, + }); + } + return this._http.delete( + '/project/' + action.payload.projectKey + '/environment/' + + action.payload.environmentName + '/variable/' + action.payload.variable.name + ).pipe(tap(() => { + const state = ctx.getState(); + let env = cloneDeep(state.environment) + env.variables = env.variables.filter(va => va.name !== action.payload.variable.name); + ctx.setState({ + ...state, + environment: env, + }); + })); + } + + @Action(ActionEnvironment.UpdateEnvironmentVariable) + updateEnvironmentVariable(ctx: StateContext, action: ActionEnvironment.UpdateEnvironmentVariable) { + const stateEditMode = ctx.getState(); + if (stateEditMode.editMode) { + delete action.payload.changes.updating; + delete action.payload.changes.hasChanged; + let envToUpdate = cloneDeep(stateEditMode.editEnvironment); + envToUpdate.variables = envToUpdate.variables.map( v => { + if (v.name === action.payload.variableName) { + return action.payload.changes; + } + return v; + }); + envToUpdate.editModeChanged = true; + return ctx.setState({ + ...stateEditMode, + editEnvironment: envToUpdate, + }); + } + return this._http.put( + '/project/' + action.payload.projectKey + '/environment/' + + action.payload.environmentName + '/variable/' + action.payload.variableName, + action.payload.changes + ).pipe(tap((v: Variable) => { + const state = ctx.getState(); + let env = cloneDeep(state.environment) + env.variables = env.variables.map(va => { + if (va.name !== action.payload.variableName) { + return va; + } + return v; + }); + ctx.setState({ + ...state, + environment: env, + }) + })); + } + + @Action(ActionEnvironment.AddEnvironmentKey) + addEnvironmentKey(ctx: StateContext, action: ActionEnvironment.AddEnvironmentKey) { + const stateEditMode = ctx.getState(); + if (stateEditMode.editMode) { + let envToUpdate = cloneDeep(stateEditMode.editEnvironment); + if (!envToUpdate.keys) { + envToUpdate.keys = new Array(); + } + envToUpdate.keys.push(action.payload.key); + envToUpdate.editModeChanged = true; + return ctx.setState({ + ...stateEditMode, + editEnvironment: envToUpdate, + }); + } + return this._http.post(`/project/${action.payload.projectKey}/environment/${action.payload.envName}/keys`, action.payload.key) + .pipe(tap((key: Key) => { + const state = ctx.getState(); + let env = cloneDeep(state.environment) + if (!env.keys) { + env.keys = new Array(); + } + env.keys.push(key); + ctx.setState({ + ...state, + environment: env + }); + })); + } + + @Action(ActionEnvironment.DeleteEnvironmentKey) + deleteEnvironmentKey(ctx: StateContext, action: ActionEnvironment.DeleteEnvironmentKey) { + const stateEditMode = ctx.getState(); + if (stateEditMode.editMode) { + let envToUpdate = cloneDeep(stateEditMode.editEnvironment); + envToUpdate.keys = envToUpdate.keys.filter(e => e.name !== action.payload.key.name); + envToUpdate.editModeChanged = true; + return ctx.setState({ + ...stateEditMode, + editEnvironment: envToUpdate, + }); + } + return this._http.delete('/project/' + action.payload.projectKey + + '/environment/' + action.payload.envName + '/keys/' + action.payload.key.name) + .pipe(tap(() => { + const state = ctx.getState(); + let env = cloneDeep(state.environment) + env.keys = env.keys.filter(k => k.name !== action.payload.key.name); + ctx.setState({ + ...state, + environment: env + }); + })); + } + + @Action(ActionEnvironment.CleanEnvironmentState) + cleanEnvironmentState(ctx: StateContext, _: ActionEnvironment.DeleteEnvironmentKey) { + ctx.setState(getInitialEnvironmentState()) ; + } + +} diff --git a/ui/src/app/store/project.action.ts b/ui/src/app/store/project.action.ts index ff1be1408a..a9a7db6e49 100644 --- a/ui/src/app/store/project.action.ts +++ b/ui/src/app/store/project.action.ts @@ -1,6 +1,5 @@ import { Application } from 'app/model/application.model'; -import { Environment } from 'app/model/environment.model'; import { GroupPermission } from 'app/model/group.model'; import { ProjectIntegration } from 'app/model/integration.model'; import { Key } from 'app/model/keys.model'; @@ -210,59 +209,6 @@ export class DeleteKeyInProject { constructor(public payload: { projectKey: string, key: Key }) { } } -// ------- Environment --------- // -export class ResyncEnvironmentsInProject { - static readonly type = '[Project] Resync Environments in Project'; - constructor(public payload: { projectKey: string }) { } -} -export class AddEnvironmentKey { - static readonly type = '[Project] Add Environment Key in Project'; - constructor(public payload: { projectKey: string, envName: string, key: Key }) { } -} -export class DeleteEnvironmentKey { - static readonly type = '[Project] Delete Environment Key in Project'; - constructor(public payload: { projectKey: string, envName: string, key: Key }) { } -} -export class FetchEnvironmentInProject { - static readonly type = '[Project] Fetch Single Environment in Project'; - constructor(public payload: { projectKey: string, envName: string }) { } -} -export class LoadEnvironmentsInProject { - static readonly type = '[Project] Load Environments in Project'; - constructor(public payload: Environment[]) { } -} -export class AddEnvironmentInProject { - static readonly type = '[Project] Add Environment in Project'; - constructor(public payload: { projectKey: string, environment: Environment }) { } -} -export class CloneEnvironmentInProject { - static readonly type = '[Project] Clone Environment in Project'; - constructor(public payload: { projectKey: string, cloneName: string, environment: Environment }) { } -} -export class UpdateEnvironmentInProject { - static readonly type = '[Project] Update environment in Project'; - constructor(public payload: { projectKey: string, environmentName: string, changes: Environment }) { } -} -export class DeleteEnvironmentInProject { - static readonly type = '[Project] Delete Environment in Project'; - constructor(public payload: { projectKey: string, environment: Environment }) { } -} -export class AddEnvironmentVariableInProject { - static readonly type = '[Project] Add Environment Variable in Project'; - constructor(public payload: { projectKey: string, environmentName: string, variable: Variable }) { } -} -export class UpdateEnvironmentVariableInProject { - static readonly type = '[Project] Update environment variable in Project'; - constructor(public payload: { projectKey: string, environmentName: string, variableName: string, changes: Variable }) { } -} -export class DeleteEnvironmentVariableInProject { - static readonly type = '[Project] Delete Environment Variable in Project'; - constructor(public payload: { projectKey: string, environmentName: string, variable: Variable }) { } -} -export class FetchEnvironmentUsageInProject { - static readonly type = '[Project] Fetch Environment usage in Project'; - constructor(public payload: { projectKey: string, environmentName: string }) { } -} // ------- Repository Manager --------- // export class ConnectRepositoryManagerInProject { static readonly type = '[Project] Connect Repository Manager in Project'; diff --git a/ui/src/app/store/project.state.spec.ts b/ui/src/app/store/project.state.spec.ts index 52617354ff..1f4a912fe6 100644 --- a/ui/src/app/store/project.state.spec.ts +++ b/ui/src/app/store/project.state.spec.ts @@ -3,7 +3,6 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { async, TestBed } from '@angular/core/testing'; import { NgxsModule, Store } from '@ngxs/store'; import { Application } from 'app/model/application.model'; -import { Environment } from 'app/model/environment.model'; import { Group, GroupPermission } from 'app/model/group.model'; import { ProjectIntegration } from 'app/model/integration.model'; import { Key } from 'app/model/keys.model'; @@ -30,6 +29,7 @@ import { RouterTestingModule } from '@angular/router/testing'; describe('Project', () => { let store: Store; + let http: HttpTestingController; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -41,12 +41,11 @@ describe('Project', () => { ], }).compileComponents(); - store = TestBed.get(Store); - // store.reset(getInitialProjectState()); + store = TestBed.inject(Store); + http = TestBed.inject(HttpTestingController); })); it('fetch project', async(() => { - const http = TestBed.get(HttpTestingController); store.dispatch(new ProjectAction.FetchProject({ projectKey: 'test1', opts: [] @@ -65,7 +64,6 @@ describe('Project', () => { })); it('fetch project with options', async(() => { - const http = TestBed.get(HttpTestingController); store.dispatch(new ProjectAction.FetchProject({ projectKey: 'test1', opts: [] @@ -119,7 +117,6 @@ describe('Project', () => { })); it('add project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -138,7 +135,6 @@ describe('Project', () => { })); it('update project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -167,7 +163,6 @@ describe('Project', () => { })); it('delete project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -192,7 +187,6 @@ describe('Project', () => { // ------- Application --------- // it('add application in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -220,7 +214,6 @@ describe('Project', () => { })); it('update application in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -261,7 +254,6 @@ describe('Project', () => { })); it('delete application in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -288,7 +280,6 @@ describe('Project', () => { // ------- Workflow --------- // it('add workflow in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -316,7 +307,6 @@ describe('Project', () => { })); it('update workflow in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -350,7 +340,6 @@ describe('Project', () => { })); it('delete workflow in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -377,7 +366,6 @@ describe('Project', () => { // ------- Pipeline --------- // it('add pipeline in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -404,7 +392,6 @@ describe('Project', () => { })); it('update pipeline in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -438,7 +425,6 @@ describe('Project', () => { })); it('delete pipeline in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -464,7 +450,6 @@ describe('Project', () => { // ------- Label --------- // it('add label to workflow in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -522,7 +507,6 @@ describe('Project', () => { })); it('delete label to workflow in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -563,7 +547,6 @@ describe('Project', () => { // ------- Variable --------- // it('add variable in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -595,7 +578,6 @@ describe('Project', () => { })); it('update variable in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -634,7 +616,6 @@ describe('Project', () => { })); it('delete variable in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -666,7 +647,6 @@ describe('Project', () => { })); it('fetch variable in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -700,7 +680,6 @@ describe('Project', () => { // ------- Group --------- // it('add group permission in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -735,7 +714,6 @@ describe('Project', () => { })); it('delete group permission in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -769,7 +747,6 @@ describe('Project', () => { })); it('update group permission in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -807,7 +784,6 @@ describe('Project', () => { // ------- Key --------- // it('add key in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -839,7 +815,6 @@ describe('Project', () => { })); it('delete key in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -872,7 +847,6 @@ describe('Project', () => { // ------- Integration --------- // it('add integration in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -902,7 +876,6 @@ describe('Project', () => { })); it('update integration in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -939,7 +912,6 @@ describe('Project', () => { })); it('delete integration in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -969,283 +941,8 @@ describe('Project', () => { }); })); - // ------- Environment --------- // - it('add environment in project', async(() => { - const http = TestBed.get(HttpTestingController); - let project = new Project(); - project.name = 'proj1'; - project.key = 'test1'; - store.dispatch(new ProjectAction.AddProject(project)); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project'; - })).flush({ - name: 'proj1', - key: 'test1', - }); - - let env = new Environment(); - env.name = 'prod'; - store.dispatch(new ProjectAction.AddEnvironmentInProject({ projectKey: project.key, environment: env })); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project/test1/environment'; - })).flush({ - ...project, - environments: [env] - }); - - store.selectOnce(ProjectState).subscribe((state: ProjectStateModel) => { - expect(state.project).toBeTruthy(); - expect(state.project.name).toEqual('proj1'); - expect(state.project.key).toEqual('test1'); - expect(state.project.environments).toBeTruthy(); - expect(state.project.environments.length).toEqual(1); - expect(state.project.environments[0].name).toEqual('prod'); - }); - })); - - it('fetch environment in project', async(() => { - const http = TestBed.get(HttpTestingController); - let project = new Project(); - project.name = 'proj1'; - project.key = 'test1'; - store.dispatch(new ProjectAction.AddProject(project)); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project'; - })).flush({ - name: 'proj1', - key: 'test1', - }); - - let env = new Environment(); - env.name = 'prod'; - store.dispatch(new ProjectAction.FetchEnvironmentInProject({ projectKey: project.key, envName: env.name })); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project/test1/environment/prod'; - })).flush(env); - - store.selectOnce(ProjectState).subscribe((state: ProjectStateModel) => { - expect(state.project).toBeTruthy(); - expect(state.project.name).toEqual('proj1'); - expect(state.project.key).toEqual('test1'); - expect(state.project.environments).toBeTruthy(); - expect(state.project.environments.length).toEqual(1); - expect(state.project.environments[0].name).toEqual('prod'); - }); - })); - - it('update environment in project', async(() => { - const http = TestBed.get(HttpTestingController); - let project = new Project(); - project.name = 'proj1'; - project.key = 'test1'; - let env = new Environment(); - env.name = 'prod'; - - store.dispatch(new ProjectAction.AddProject(project)); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project'; - })).flush({ - name: 'proj1', - key: 'test1', - environments: [env] - }); - - env.name = 'dev'; - store.dispatch(new ProjectAction.UpdateEnvironmentInProject({ - projectKey: project.key, - environmentName: 'prod', - changes: env - })); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project/test1/environment/prod'; - })).flush({ - ...project, - environments: [env] - }); - - store.selectOnce(ProjectState).subscribe((state: ProjectStateModel) => { - expect(state.project).toBeTruthy(); - expect(state.project.name).toEqual('proj1'); - expect(state.project.key).toEqual('test1'); - expect(state.project.environments).toBeTruthy(); - expect(state.project.environments.length).toEqual(1); - expect(state.project.environments[0].name).toEqual('dev'); - }); - })); - - it('delete environment in project', async(() => { - const http = TestBed.get(HttpTestingController); - let project = new Project(); - project.name = 'proj1'; - project.key = 'test1'; - let env = new Environment(); - env.name = 'prod'; - - store.dispatch(new ProjectAction.AddProject(project)); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project'; - })).flush({ - name: 'proj1', - key: 'test1', - environments: [env] - }); - - store.dispatch(new ProjectAction.DeleteEnvironmentInProject({ projectKey: project.key, environment: env })); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project/test1/environment/prod'; - })).flush({ - ...project, - environments: [] - }); - - store.selectOnce(ProjectState).subscribe((state: ProjectStateModel) => { - expect(state.project).toBeTruthy(); - expect(state.project.name).toEqual('proj1'); - expect(state.project.key).toEqual('test1'); - expect(state.project.environments).toBeTruthy(); - expect(state.project.environments.length).toEqual(0); - }); - })); - - it('add environment variable in project', async(() => { - const http = TestBed.get(HttpTestingController); - let project = new Project(); - project.name = 'proj1'; - project.key = 'test1'; - store.dispatch(new ProjectAction.AddProject(project)); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project'; - })).flush({ - name: 'proj1', - key: 'test1', - environments: [{ name: 'prod' }] - }); - - let env = new Environment(); - env.name = 'prod'; - let variable = new Variable(); - variable.name = 'testvar'; - variable.type = 'string'; - variable.value = 'myvalue'; - env.variables = [variable]; - store.dispatch(new ProjectAction.AddEnvironmentVariableInProject({ - projectKey: project.key, - environmentName: env.name, - variable - })); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project/test1/environment/prod/variable/testvar'; - })).flush(variable); - - store.selectOnce(ProjectState).subscribe((state: ProjectStateModel) => { - expect(state.project).toBeTruthy(); - expect(state.project.name).toEqual('proj1'); - expect(state.project.key).toEqual('test1'); - expect(state.project.environments).toBeTruthy(); - expect(state.project.environments.length).toEqual(1); - expect(state.project.environments[0].name).toEqual('prod'); - expect(state.project.environments[0].variables).toBeTruthy(); - expect(state.project.environments[0].variables.length).toEqual(1); - expect(state.project.environments[0].variables[0].name).toEqual('testvar'); - }); - })); - - it('update environment variable in project', async(() => { - const http = TestBed.get(HttpTestingController); - let project = new Project(); - project.name = 'proj1'; - project.key = 'test1'; - let env = new Environment(); - env.name = 'prod'; - let variable = new Variable(); - variable.name = 'testvar'; - variable.type = 'string'; - variable.value = 'myvalue'; - env.variables = [variable]; - - store.dispatch(new ProjectAction.AddProject(project)); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project'; - })).flush({ - name: 'proj1', - key: 'test1', - environments: [env] - }); - - variable.name = 'testvarbis'; - store.dispatch(new ProjectAction.UpdateEnvironmentVariableInProject({ - projectKey: project.key, - environmentName: env.name, - variableName: 'testvar', - changes: variable - })); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project/test1/environment/prod/variable/testvar'; - })).flush({ - ...project, - environments: [Object.assign({}, env, { variables: [variable] })] - }); - - store.selectOnce(ProjectState).subscribe((state: ProjectStateModel) => { - expect(state.project).toBeTruthy(); - expect(state.project.name).toEqual('proj1'); - expect(state.project.key).toEqual('test1'); - expect(state.project.environments).toBeTruthy(); - expect(state.project.environments.length).toEqual(1); - expect(state.project.environments[0].variables).toBeTruthy(); - expect(state.project.environments[0].variables.length).toEqual(1); - expect(state.project.environments[0].variables[0].name).toEqual('testvarbis'); - }); - })); - - it('delete environment variable in project', async(() => { - const http = TestBed.get(HttpTestingController); - let project = new Project(); - project.name = 'proj1'; - project.key = 'test1'; - let env = new Environment(); - env.name = 'prod'; - let variable = new Variable(); - variable.name = 'testvar'; - variable.type = 'string'; - variable.value = 'myvalue'; - env.variables = [variable]; - - store.dispatch(new ProjectAction.AddProject(project)); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project'; - })).flush({ - name: 'proj1', - key: 'test1', - environments: [env] - }); - - store.dispatch(new ProjectAction.DeleteEnvironmentVariableInProject({ - projectKey: project.key, - environmentName: env.name, - variable - })); - http.expectOne(((req: HttpRequest) => { - return req.url === '/project/test1/environment/prod/variable/testvar'; - })).flush({ - ...project, - environments: [Object.assign({}, env, { variables: [] })] - }); - - store.selectOnce(ProjectState).subscribe((state: ProjectStateModel) => { - expect(state.project).toBeTruthy(); - expect(state.project.name).toEqual('proj1'); - expect(state.project.key).toEqual('test1'); - expect(state.project.environments).toBeTruthy(); - expect(state.project.environments.length).toEqual(1); - expect(state.project.environments[0].variables).toBeTruthy(); - expect(state.project.environments[0].variables.length).toEqual(0); - }); - })); - // ------- Repository Manager --------- // it('connect repository manager variable in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -1279,7 +976,6 @@ describe('Project', () => { })); it('callback repository manager basic auth in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -1318,7 +1014,6 @@ describe('Project', () => { it('callback repository manager variable in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; @@ -1356,7 +1051,6 @@ describe('Project', () => { })); it('disconnect repository manager variable in project', async(() => { - const http = TestBed.get(HttpTestingController); let project = new Project(); project.name = 'proj1'; project.key = 'test1'; diff --git a/ui/src/app/store/project.state.ts b/ui/src/app/store/project.state.ts index 9795aa4bf9..df77637e12 100644 --- a/ui/src/app/store/project.state.ts +++ b/ui/src/app/store/project.state.ts @@ -6,7 +6,6 @@ import { GroupPermission } from 'app/model/group.model'; import { ProjectIntegration } from 'app/model/integration.model'; import { Key } from 'app/model/keys.model'; import { IdName, Label, LoadOpts, Project } from 'app/model/project.model'; -import { Usage } from 'app/model/usage.model'; import { Variable } from 'app/model/variable.model'; import { EnvironmentService } from 'app/service/environment/environment.service'; import { NavbarService } from 'app/service/navbar/navbar.service'; @@ -851,220 +850,6 @@ export class ProjectState { })); } - // ------- Environment --------- // - @Action(ProjectAction.FetchEnvironmentInProject) - fetchEnvironment(ctx: StateContext, action: ProjectAction.FetchEnvironmentInProject) { - const state = ctx.getState(); - - return this._envService.getEnvironment(action.payload.projectKey, action.payload.envName) - .pipe(tap((environment: Environment) => { - let envs = state.project.environments; - if (Array.isArray(envs)) { - envs = envs.map((env) => { - if (env.name === action.payload.envName) { - return environment; - } - return env; - }) - } else { - envs = [environment]; - } - ctx.setState({ - ...state, - project: { - ...state.project, - environments: envs, - } - }); - })); - } - - @Action(ProjectAction.AddEnvironmentKey) - addEnvironmentKey(ctx: StateContext, action: ProjectAction.AddEnvironmentKey) { - const state = ctx.getState(); - return this._http.post(`/project/${action.payload.projectKey}/environment/${action.payload.envName}/keys`, action.payload.key) - .pipe(tap((key: Key) => { - let envs = state.project.environments; - if (Array.isArray(envs)) { - envs = envs.map((env) => { - if (env.name === action.payload.envName) { - return { ...env, keys: [key].concat(env.keys) }; - } - return env; - }) - } - ctx.setState({ - ...state, - project: { - ...state.project, - environments: envs, - } - }); - })); - } - - @Action(ProjectAction.DeleteEnvironmentKey) - deleteEnvironmentKey(ctx: StateContext, action: ProjectAction.DeleteEnvironmentKey) { - const state = ctx.getState(); - return this._http.delete('/project/' + action.payload.projectKey + - '/environment/' + action.payload.envName + '/keys/' + action.payload.key.name) - .pipe(tap(() => { - let envs = state.project.environments; - if (Array.isArray(envs)) { - envs = envs.map((env) => { - if (env.name === action.payload.envName) { - return { ...env, keys: env.keys.filter((key) => key.name === action.payload.key.name) }; - } - return env; - }) - } - ctx.setState({ - ...state, - project: { - ...state.project, - environments: envs, - } - }); - })); - } - - @Action(ProjectAction.LoadEnvironmentsInProject) - loadEnvironments(ctx: StateContext, action: ProjectAction.LoadEnvironmentsInProject) { - const state = ctx.getState(); - ctx.setState({ - ...state, - project: Object.assign({}, state.project, { environments: action.payload }), - }); - } - - @Action(ProjectAction.ResyncEnvironmentsInProject) - resyncEnvironments(ctx: StateContext, action: ProjectAction.ResyncEnvironmentsInProject) { - let params = new HttpParams(); - params = params.append('withUsage', 'true'); - return this._http - .get(`/project/${action.payload.projectKey}/environment`, { params }) - .pipe(tap((environments: Environment[]) => { - ctx.dispatch(new ProjectAction.LoadEnvironmentsInProject(environments)); - })); - } - - @Action(ProjectAction.AddEnvironmentInProject) - addEnvironment(ctx: StateContext, action: ProjectAction.AddEnvironmentInProject) { - return this._http.post('/project/' + action.payload.projectKey + '/environment', action.payload.environment) - .pipe(tap((project: Project) => ctx.dispatch(new ProjectAction.LoadEnvironmentsInProject(project.environments)))); - } - - @Action(ProjectAction.CloneEnvironmentInProject) - cloneEnvironment(ctx: StateContext, action: ProjectAction.CloneEnvironmentInProject) { - return this._http.post( - '/project/' + action.payload.projectKey + '/environment/' + - action.payload.environment.name + '/clone/' + action.payload.cloneName, - null - ).pipe(tap((project: Project) => ctx.dispatch(new ProjectAction.LoadEnvironmentsInProject(project.environments)))); - } - - @Action(ProjectAction.DeleteEnvironmentInProject) - deleteEnvironment(ctx: StateContext, action: ProjectAction.DeleteEnvironmentInProject) { - return this._http.delete('/project/' + action.payload.projectKey + '/environment/' + action.payload.environment.name) - .pipe(tap((project: Project) => ctx.dispatch(new ProjectAction.LoadEnvironmentsInProject(project.environments)))); - } - - @Action(ProjectAction.UpdateEnvironmentInProject) - updateEnvironment(ctx: StateContext, action: ProjectAction.UpdateEnvironmentInProject) { - return this._http.put( - '/project/' + action.payload.projectKey + '/environment/' + action.payload.environmentName, - action.payload.changes - ).pipe(tap((project: Project) => { - ctx.dispatch(new ProjectAction.LoadEnvironmentsInProject(project.environments)); - })); - } - - @Action(ProjectAction.AddEnvironmentVariableInProject) - addEnvironmentVariable(ctx: StateContext, action: ProjectAction.AddEnvironmentVariableInProject) { - return this._http.post( - '/project/' + action.payload.projectKey + '/environment/' + - action.payload.environmentName + '/variable/' + action.payload.variable.name, - action.payload.variable - ).pipe(tap((v: Variable) => { - const state = ctx.getState(); - let proj = cloneDeep(state.project); - let env = proj.environments.find(e => e.name === action.payload.environmentName); - if (!env) { - return; - } - if (!env.variables) { - env.variables = new Array(); - } - env.variables.push(v); - ctx.setState({ - ...state, - project: proj, - }); - })); - } - - @Action(ProjectAction.DeleteEnvironmentVariableInProject) - deleteEnvironmentVariable(ctx: StateContext, action: ProjectAction.DeleteEnvironmentVariableInProject) { - return this._http.delete( - '/project/' + action.payload.projectKey + '/environment/' + - action.payload.environmentName + '/variable/' + action.payload.variable.name - ).pipe(tap((v: Variable) => { - const state = ctx.getState(); - let proj = cloneDeep(state.project); - let env = proj.environments.find(e => e.name === action.payload.environmentName); - if (!env) { - return; - } - env.variables = env.variables.filter(va => va.name !== action.payload.variable.name); - ctx.setState({ - ...state, - project: proj, - }); - })); - } - - @Action(ProjectAction.UpdateEnvironmentVariableInProject) - updateEnvironmentVariable(ctx: StateContext, action: ProjectAction.UpdateEnvironmentVariableInProject) { - return this._http.put( - '/project/' + action.payload.projectKey + '/environment/' + - action.payload.environmentName + '/variable/' + action.payload.variableName, - action.payload.changes - ).pipe(tap((v: Variable) => { - const state = ctx.getState(); - let proj = cloneDeep(state.project); - let env = proj.environments.find(e => e.name === action.payload.environmentName); - if (!env) { - return; - } - env.variables = env.variables.map(va => { - if (va.name !== action.payload.variableName) { - return va; - } - return v; - }); - ctx.setState({ - ...state, - project: proj, - }) - })); - } - - @Action(ProjectAction.FetchEnvironmentUsageInProject) - fetchEnvironmentUsage(ctx: StateContext, action: ProjectAction.FetchEnvironmentUsageInProject) { - return this._http - .get(`/project/${action.payload.projectKey}/environment/${action.payload.environmentName}/usage`) - .pipe(tap((usage: Usage) => { - const state = ctx.getState(); - const environments = state.project.environments.map((env) => { - if (env.name === action.payload.environmentName) { - return { ...env, usage }; - } - return env; - }); - return ctx.dispatch(new ProjectAction.LoadEnvironmentsInProject(environments)); - })); - } - // ------- Repository Manager --------- // @Action(ProjectAction.ConnectRepositoryManagerInProject) connectRepositoryManager(ctx: StateContext, action: ProjectAction.ConnectRepositoryManagerInProject) { diff --git a/ui/src/app/store/store.module.ts b/ui/src/app/store/store.module.ts index dc8664b00b..0f2e5fc150 100644 --- a/ui/src/app/store/store.module.ts +++ b/ui/src/app/store/store.module.ts @@ -7,6 +7,7 @@ import { ServicesModule } from 'app/service/services.module'; import { SharedModule } from 'app/shared/shared.module'; import { ApplicationsState } from 'app/store/applications.state'; import { CDSState } from 'app/store/cds.state'; +import { EnvironmentState } from 'app/store/environment.state'; import { PipelinesState } from 'app/store/pipelines.state'; import { environment as env } from '../../environments/environment'; import { AuthenticationState } from './authentication.state'; @@ -24,9 +25,10 @@ import { WorkflowState } from './workflow.state'; AuthenticationState, ApplicationsState, CDSState, + EnvironmentState, ProjectState, PipelinesState, - WorkflowState + WorkflowState, ], { developmentMode: !env.production }) ], exports: [ diff --git a/ui/src/app/views/application/show/keys/application.keys.component.ts b/ui/src/app/views/application/show/keys/application.keys.component.ts index 4c8ca4e696..e2ed484652 100644 --- a/ui/src/app/views/application/show/keys/application.keys.component.ts +++ b/ui/src/app/views/application/show/keys/application.keys.component.ts @@ -1,12 +1,12 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { Store } from '@ngxs/store'; +import { Application } from 'app/model/application.model'; +import { Project } from 'app/model/project.model'; +import { KeyEvent } from 'app/shared/keys/key.event'; +import { ToastService } from 'app/shared/toast/ToastService'; import { AddApplicationKey, DeleteApplicationKey } from 'app/store/applications.action'; import { finalize } from 'rxjs/operators'; -import { Application } from '../../../../model/application.model'; -import { Project } from '../../../../model/project.model'; -import { KeyEvent } from '../../../../shared/keys/key.event'; -import { ToastService } from '../../../../shared/toast/ToastService'; @Component({ selector: 'app-application-keys', diff --git a/ui/src/app/views/environment/add/environment.add.component.ts b/ui/src/app/views/environment/add/environment.add.component.ts index 0b73803441..6b9579f2d4 100644 --- a/ui/src/app/views/environment/add/environment.add.component.ts +++ b/ui/src/app/views/environment/add/environment.add.component.ts @@ -6,7 +6,7 @@ import { Environment } from 'app/model/environment.model'; import { Project } from 'app/model/project.model'; import { AutoUnsubscribe } from 'app/shared/decorator/autoUnsubscribe'; import { ToastService } from 'app/shared/toast/ToastService'; -import { AddEnvironmentInProject } from 'app/store/project.action'; +import { AddEnvironment } from 'app/store/environment.action'; import { Subscription } from 'rxjs'; import { finalize } from 'rxjs/operators'; @@ -52,7 +52,7 @@ export class EnvironmentAddComponent { } this.loading = true; - this.store.dispatch(new AddEnvironmentInProject({ projectKey: this.project.key, environment: this.newEnvironment })) + this.store.dispatch(new AddEnvironment({ projectKey: this.project.key, environment: this.newEnvironment })) .pipe(finalize(() => this.loading = false)) .subscribe(() => { this._toast.success('', this._translate.instant('environment_created')); diff --git a/ui/src/app/views/environment/show/advanced/environment.advanced.component.ts b/ui/src/app/views/environment/show/advanced/environment.advanced.component.ts index 6476405b14..90848b950f 100644 --- a/ui/src/app/views/environment/show/advanced/environment.advanced.component.ts +++ b/ui/src/app/views/environment/show/advanced/environment.advanced.component.ts @@ -8,10 +8,10 @@ import { AuthentifiedUser } from 'app/model/user.model'; import { ToastService } from 'app/shared/toast/ToastService'; import { AuthenticationState } from 'app/store/authentication.state'; import { - CloneEnvironmentInProject, - DeleteEnvironmentInProject, - UpdateEnvironmentInProject -} from 'app/store/project.action'; + CloneEnvironment, + DeleteEnvironment, + UpdateEnvironment +} from 'app/store/environment.action'; import { finalize } from 'rxjs/operators'; @Component({ @@ -49,7 +49,7 @@ export class EnvironmentAdvancedComponent implements OnInit { onSubmitEnvironmentUpdate(): void { this.loading = true; - this.store.dispatch(new UpdateEnvironmentInProject({ + this.store.dispatch(new UpdateEnvironment({ projectKey: this.project.key, environmentName: this.oldName, changes: this.environment @@ -65,7 +65,7 @@ export class EnvironmentAdvancedComponent implements OnInit { cloneEnvironment(cloneModal?: any): void { this.loading = true; - this.store.dispatch(new CloneEnvironmentInProject({ + this.store.dispatch(new CloneEnvironment({ projectKey: this.project.key, cloneName: this.cloneName, environment: this.environment @@ -82,7 +82,7 @@ export class EnvironmentAdvancedComponent implements OnInit { deleteEnvironment(): void { this.loading = true; - this.store.dispatch(new DeleteEnvironmentInProject({ + this.store.dispatch(new DeleteEnvironment({ projectKey: this.project.key, environment: this.environment })).pipe(finalize(() => this.loading = false)) .subscribe(() => { diff --git a/ui/src/app/views/environment/show/environment.show.component.ts b/ui/src/app/views/environment/show/environment.show.component.ts index 6056b15c17..acdbfd8bc6 100644 --- a/ui/src/app/views/environment/show/environment.show.component.ts +++ b/ui/src/app/views/environment/show/environment.show.component.ts @@ -1,22 +1,24 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { Store } from '@ngxs/store'; import { Environment } from 'app/model/environment.model'; -import { Pipeline } from 'app/model/pipeline.model'; import { Project } from 'app/model/project.model'; import { AuthentifiedUser } from 'app/model/user.model'; import { Workflow } from 'app/model/workflow.model'; +import { AsCodeSaveModalComponent } from 'app/shared/ascode/save-modal/ascode.save-modal.component'; import { AutoUnsubscribe } from 'app/shared/decorator/autoUnsubscribe'; import { ToastService } from 'app/shared/toast/ToastService'; import { VariableEvent } from 'app/shared/variable/variable.event.model'; import { CDSWebWorker } from 'app/shared/worker/web.worker'; import { AuthenticationState } from 'app/store/authentication.state'; -import * as projectActions from 'app/store/project.action'; +import { CleanEnvironmentState } from 'app/store/environment.action'; +import * as envActions from 'app/store/environment.action'; +import { EnvironmentState, EnvironmentStateModel } from 'app/store/environment.state'; import { ProjectState, ProjectStateModel } from 'app/store/project.state'; import { cloneDeep } from 'lodash-es'; import { Subscription } from 'rxjs'; -import { filter, finalize } from 'rxjs/operators'; +import { finalize } from 'rxjs/operators'; @Component({ selector: 'app-environment-show', @@ -25,7 +27,10 @@ import { filter, finalize } from 'rxjs/operators'; changeDetection: ChangeDetectionStrategy.OnPush }) @AutoUnsubscribe() -export class EnvironmentShowComponent implements OnInit { +export class EnvironmentShowComponent implements OnInit, OnDestroy { + + @ViewChild('updateEditMode') + asCodeSaveModal: AsCodeSaveModalComponent; // Flag to show the page or not public readyEnv = false; @@ -35,6 +40,9 @@ export class EnvironmentShowComponent implements OnInit { // Project & Application data project: Project; environment: Environment; + readOnlyEnvironment: Environment; + editMode: boolean; + readonly: boolean; // Subscription environmentSubscription: Subscription; @@ -53,9 +61,7 @@ export class EnvironmentShowComponent implements OnInit { workflowNodeRun: string; workflowPipeline: string; - pipelines: Array = new Array(); workflows: Array = new Array(); - environments: Array = new Array(); currentUser: AuthentifiedUser; usageCount = 0; @@ -64,18 +70,17 @@ export class EnvironmentShowComponent implements OnInit { private _router: Router, private _toast: ToastService, public _translate: TranslateService, - private store: Store, + private _store: Store, private _cd: ChangeDetectorRef ) { - this.currentUser = this.store.selectSnapshot(AuthenticationState.user); - // Update data if route change + this.currentUser = this._store.selectSnapshot(AuthenticationState.user); + this.project = this._route.snapshot.data['project']; + this.projectSubscription = this._store.select(ProjectState)// Update data if route change + .subscribe((projectState: ProjectStateModel) => this.project = projectState.project); this._routeDataSub = this._route.data.subscribe(datas => { this.project = datas['project']; }); - this.projectSubscription = this.store.select(ProjectState) - .subscribe((projectState: ProjectStateModel) => this.project = projectState.project); - if (this._route.snapshot && this._route.queryParams) { this.workflowName = this._route.snapshot.queryParams['workflow']; this.workflowNum = this._route.snapshot.queryParams['run']; @@ -87,7 +92,7 @@ export class EnvironmentShowComponent implements OnInit { let key = params['key']; let envName = params['envName']; if (key && envName) { - this.store.dispatch(new projectActions.FetchEnvironmentInProject({ projectKey: key, envName })) + this._store.dispatch(new envActions.FetchEnvironment({ projectKey: key, envName })) .subscribe( null, () => this._router.navigate(['/project', key], { queryParams: { tab: 'environments' } }) @@ -101,16 +106,26 @@ export class EnvironmentShowComponent implements OnInit { this.environmentSubscription.unsubscribe(); } - this.environmentSubscription = this.store.select(ProjectState.selectEnvironment(envName)) - .pipe(filter((env) => env != null)) - .subscribe((env: Environment) => { + this.environmentSubscription = this._store.select(EnvironmentState.currentState()) + .subscribe((s: EnvironmentStateModel) => { + if (!s.environment) { + return; + } + this.editMode = s.editMode; + this.readonly = (s.environment.workflow_ascode_holder && !!s.environment.workflow_ascode_holder.from_template) + || !this.project.permissions.writable; + if (s.editMode) { + this.environment = cloneDeep(s.editEnvironment); + this.readOnlyEnvironment = cloneDeep(s.environment); + } else { + this.environment = cloneDeep(s.environment); + this.readOnlyEnvironment = cloneDeep(s.environment); + } this.readyEnv = true; - this.environment = cloneDeep(env); - if (env.usage) { - this.workflows = env.usage.workflows || []; - this.environments = env.usage.environments || []; - this.pipelines = env.usage.pipelines || []; - this.usageCount = this.pipelines.length + this.environments.length + this.workflows.length; + + if (this.environment.usage) { + this.workflows = this.environment.usage.workflows || []; + this.usageCount = this.workflows.length; } this._cd.markForCheck(); }, () => { @@ -130,6 +145,10 @@ export class EnvironmentShowComponent implements OnInit { }); } + ngOnDestroy() { + this._store.dispatch(new CleanEnvironmentState()) + } + showTab(tab: string): void { this._router.navigateByUrl('/project/' + this.project.key + '/environment/' + this.environment.name + '?tab=' + tab); } @@ -143,7 +162,7 @@ export class EnvironmentShowComponent implements OnInit { switch (event.type) { case 'add': this.varFormLoading = true; - this.store.dispatch(new projectActions.AddEnvironmentVariableInProject({ + this._store.dispatch(new envActions.AddEnvironmentVariable({ projectKey: this.project.key, environmentName: this.environment.name, variable: event.variable @@ -151,10 +170,17 @@ export class EnvironmentShowComponent implements OnInit { this.varFormLoading = false; this._cd.markForCheck(); })) - .subscribe(() => this._toast.success('', this._translate.instant('variable_added'))); + .subscribe(() => { + if (this.editMode) { + this._toast.info('', this._translate.instant('environment_ascode_updated')) + } else { + this._toast.success('', this._translate.instant('variable_added')); + } + + }); break; case 'update': - this.store.dispatch(new projectActions.UpdateEnvironmentVariableInProject({ + this._store.dispatch(new envActions.UpdateEnvironmentVariable({ projectKey: this.project.key, environmentName: this.environment.name, variableName: event.variable.name, @@ -163,10 +189,16 @@ export class EnvironmentShowComponent implements OnInit { event.variable.updating = false; this._cd.markForCheck(); })) - .subscribe(() => this._toast.success('', this._translate.instant('variable_updated'))); + .subscribe(() => { + if (this.editMode) { + this._toast.info('', this._translate.instant('environment_ascode_updated')) + } else { + this._toast.success('', this._translate.instant('variable_updated')) + } + }); break; case 'delete': - this.store.dispatch(new projectActions.DeleteEnvironmentVariableInProject({ + this._store.dispatch(new envActions.DeleteEnvironmentVariable({ projectKey: this.project.key, environmentName: this.environment.name, variable: event.variable @@ -174,8 +206,27 @@ export class EnvironmentShowComponent implements OnInit { event.variable.updating = false; this._cd.markForCheck(); })) - .subscribe(() => this._toast.success('', this._translate.instant('variable_deleted'))); + .subscribe(() => { + if (this.editMode) { + this._toast.info('', this._translate.instant('environment_ascode_updated')) + } else { + this._toast.success('', this._translate.instant('variable_deleted')) + } + }); break; } } + + cancelEnvironment(): void { + if (this.editMode) { + this._store.dispatch(new CleanEnvironmentState()); + } + } + + saveEditMode(): void { + if (this.editMode && this.environment.from_repository && this.asCodeSaveModal) { + // show modal to save as code + this.asCodeSaveModal.show(this.environment, 'environment'); + } + } } diff --git a/ui/src/app/views/environment/show/environment.show.html b/ui/src/app/views/environment/show/environment.show.html index dff495e76f..8898379468 100644 --- a/ui/src/app/views/environment/show/environment.show.html +++ b/ui/src/app/views/environment/show/environment.show.html @@ -37,6 +37,14 @@ {{ 'common_advanced' | translate }} +
+ + +
+
+ +
@@ -46,25 +54,23 @@
+ *ngIf="!readonly">

{{ 'environment_variable_form_title' | translate}}

{{ 'environment_variable_list_title' | translate}}

+ [environmentName]="environment.name">
- - +
- +
@@ -85,3 +91,8 @@

{{ 'environment_variable_list_title' | translate}}

Loading environment...
+ + + + diff --git a/ui/src/app/views/environment/show/environment.show.scss b/ui/src/app/views/environment/show/environment.show.scss index 01d057c479..e662d1a1b1 100644 --- a/ui/src/app/views/environment/show/environment.show.scss +++ b/ui/src/app/views/environment/show/environment.show.scss @@ -7,4 +7,13 @@ .scrollingContent { overflow-x: auto; } + + sm-menu { + .ui.buttons { + position: absolute; + right: 15px; + margin-top: 2px; + } + } } + diff --git a/ui/src/app/views/environment/show/keys/environment.keys.component.ts b/ui/src/app/views/environment/show/keys/environment.keys.component.ts index 0f8895bcdb..fd8bcfc024 100644 --- a/ui/src/app/views/environment/show/keys/environment.keys.component.ts +++ b/ui/src/app/views/environment/show/keys/environment.keys.component.ts @@ -5,7 +5,7 @@ import { Environment } from 'app/model/environment.model'; import { Project } from 'app/model/project.model'; import { KeyEvent } from 'app/shared/keys/key.event'; import { ToastService } from 'app/shared/toast/ToastService'; -import { AddEnvironmentKey, DeleteEnvironmentKey } from 'app/store/project.action'; +import { AddEnvironmentKey, DeleteEnvironmentKey } from 'app/store/environment.action'; import { finalize } from 'rxjs/operators'; @Component({ @@ -18,6 +18,8 @@ export class EnvironmentKeysComponent { @Input() project: Project; @Input() environment: Environment; + @Input() readonly: boolean; + @Input() editMode: boolean; loading = false; @@ -42,7 +44,13 @@ export class EnvironmentKeysComponent { this.loading = false; this._cd.markForCheck(); })) - .subscribe(() => this._toast.success('', this._translate.instant('keys_added'))); + .subscribe(() => { + if (this.editMode) { + this._toast.info('', this._translate.instant('environment_ascode_updated')) + } else { + this._toast.success('', this._translate.instant('keys_added')) + } + }); break; case 'delete': this.loading = true; @@ -53,7 +61,13 @@ export class EnvironmentKeysComponent { })).pipe(finalize(() => { this.loading = false; this._cd.markForCheck(); - })).subscribe(() => this._toast.success('', this._translate.instant('keys_removed'))); + })).subscribe(() => { + if (this.editMode) { + this._toast.info('', this._translate.instant('environment_ascode_updated')) + } else { + this._toast.success('', this._translate.instant('keys_removed')) + } + }); } } } diff --git a/ui/src/app/views/environment/show/keys/environment.keys.html b/ui/src/app/views/environment/show/keys/environment.keys.html index ff0b4534ac..29497c7fc3 100644 --- a/ui/src/app/views/environment/show/keys/environment.keys.html +++ b/ui/src/app/views/environment/show/keys/environment.keys.html @@ -7,10 +7,10 @@

{{ 'keys_list_title' | translate}}

+ [edit]="!readonly" (deleteEvent)="manageKeyEvent($event)"> - +

{{ 'keys_add_title' | translate }}

diff --git a/ui/src/assets/i18n/en.json b/ui/src/assets/i18n/en.json index 510e7eaa8c..ff2cc43808 100644 --- a/ui/src/assets/i18n/en.json +++ b/ui/src/assets/i18n/en.json @@ -101,7 +101,6 @@ "ascode_error_unknown_type": "Cannot determine the resource to update", "ascode_modal_title": "Save as code", "ascode_modal_label_branch": "Select or create a branch", - "ascode_modal_label_branch_new": "Branch", "ascode_modal_label_message": "Commit message", "ascode_save_modal_info_line_1": "A pull request will be created to the repository {{repo}}.", "static_file_name": "Static filenames", @@ -136,6 +135,7 @@ "btn_save_application": "Save application", "btn_save_pipeline": "Save pipeline", "btn_save_workflow": "Save workflow", + "btn_save_environment": "Save environment", "btn_next": "Next", "btn_add": "Add", "btn_back": "Back", @@ -339,6 +339,7 @@ "deployment_integration_name": "Integration / deployment", "danger_zone": "Danger Zone", "downloads_title": "Download", + "environment_ascode_updated": "Draft updated", "environment_created": "Environment added", "environment_name_error": "Invalid environment name. Allowed pattern is: a-zA-Z0-9._-", "environment_create": "Add an environment", diff --git a/ui/src/assets/i18n/fr.json b/ui/src/assets/i18n/fr.json index f913121d62..9422890973 100644 --- a/ui/src/assets/i18n/fr.json +++ b/ui/src/assets/i18n/fr.json @@ -103,7 +103,6 @@ "ascode_error_unknown_type": "Impossible de détecter la ressource à mettre à jour", "ascode_modal_title": "Sauvegarder as code", "ascode_modal_label_branch": "Sélectionner ou créer une branche", - "ascode_modal_label_branch_new": "Branche", "ascode_modal_label_message": "Message de commit", "ascode_save_modal_info_line_1": "Une \"pull request\" sera créée sur le gestionnaire de dépôt {{repo}}.", "audit_change": "Modification", @@ -157,6 +156,7 @@ "btn_save_application": "Sauvegarder l'application", "btn_save_pipeline": "Sauvegarder le pipeline", "btn_save_workflow": "Sauvegarder le workflow", + "btn_save_environment": "Sauvegarder l'environment", "btn_synchronize": "Synchronizer", "btn_validate": "Valider", "cdsctl_choice_arch": "Choisissez votre architecture", @@ -341,6 +341,7 @@ "danger_zone": "Zone dangereuse", "deployment_integration_name": "Intégration / déploiement", "downloads_title": "Téléchargements", + "environment_ascode_updated": "Brouillon mis a jour", "environment_clone_placeholder": "Nom du nouvel environnement", "environment_cloned": "Environnement cloné sans les variables mots de passe et les clés", "environment_create": "Ajouter un environnement",