Browse Source

Merge pull request #2552 from porter-dev/preview-env-v2-fe

feat: UI revamp for preview environments v2
abelanger5 3 years ago
parent
commit
b32d564758
85 changed files with 3530 additions and 1136 deletions
  1. 2 1
      api/server/handlers/environment/create.go
  2. 21 0
      api/server/handlers/environment/enable_pull_request.go
  3. 12 0
      api/server/handlers/environment/list_deployments_by_cluster.go
  4. 118 0
      api/server/handlers/environment/update_environment_settings.go
  5. 17 0
      api/server/handlers/webhook/github_incoming.go
  6. 30 0
      api/server/router/cluster.go
  7. 16 6
      api/types/environment.go
  8. 68 0
      dashboard/package-lock.json
  9. 3 0
      dashboard/package.json
  10. 13 8
      dashboard/src/App.tsx
  11. 7 1
      dashboard/src/components/DocsHelper.tsx
  12. 34 0
      dashboard/src/components/OldPlaceholder.tsx
  13. 426 0
      dashboard/src/components/OldTable.tsx
  14. 40 5
      dashboard/src/components/Placeholder.tsx
  15. 1 1
      dashboard/src/components/ProvisionerStatus.tsx
  16. 30 11
      dashboard/src/components/SearchSelector.tsx
  17. 25 350
      dashboard/src/components/Table.tsx
  18. 16 2
      dashboard/src/components/TitleSection.tsx
  19. 16 4
      dashboard/src/components/form-components/CheckboxRow.tsx
  20. 1 1
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  21. 5 4
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  22. 3 23
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  23. 1 1
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  24. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx
  25. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  26. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx
  27. 1 1
      dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx
  28. 2 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  29. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection.tsx
  30. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/DeployStatusSection.tsx
  31. 6 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventList.tsx
  32. 0 99
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventTable.tsx
  33. 168 20
      dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx
  34. 90 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/BranchFilterSelector.tsx
  35. 1 2
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx
  36. 164 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/NamespaceAnnotations.tsx
  37. 98 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/PorterYAMLErrorsModal.tsx
  38. 14 13
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx
  39. 213 60
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx
  40. 284 82
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx
  41. 358 266
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  42. 0 33
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/PullRequestCard.tsx
  43. 461 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateEnvironment.tsx
  44. 11 12
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx
  45. 484 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentSettings.tsx
  46. 31 51
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx
  47. 3 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/errors.ts
  48. 13 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx
  49. 6 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts
  50. 50 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/utils.ts
  51. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx
  52. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx
  53. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/Store.tsx
  54. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx
  55. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx
  56. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx
  57. 17 10
      dashboard/src/main/home/dashboard/Dashboard.tsx
  58. 1 1
      dashboard/src/main/home/infrastructure/ExpandedInfra.tsx
  59. 2 2
      dashboard/src/main/home/infrastructure/InfrastructureList.tsx
  60. 1 1
      dashboard/src/main/home/infrastructure/components/DeployList.tsx
  61. 1 1
      dashboard/src/main/home/infrastructure/components/ExpandedOperation.tsx
  62. 1 1
      dashboard/src/main/home/infrastructure/components/InfraResourceList.tsx
  63. 1 1
      dashboard/src/main/home/infrastructure/components/ProvisionInfra.tsx
  64. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/AWSCredentialForm.tsx
  65. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/AWSCredentialList.tsx
  66. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialForm.tsx
  67. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialList.tsx
  68. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/ClusterList.tsx
  69. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/DOCredentialList.tsx
  70. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/GCPCredentialForm.tsx
  71. 1 1
      dashboard/src/main/home/infrastructure/components/credentials/GCPCredentialList.tsx
  72. 2 2
      dashboard/src/main/home/navbar/Feedback.tsx
  73. 3 2
      dashboard/src/main/home/navbar/Help.tsx
  74. 3 3
      dashboard/src/main/home/navbar/Navbar.tsx
  75. 1 1
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx
  76. 1 1
      dashboard/src/main/home/project-settings/APITokensSection.tsx
  77. 1 1
      dashboard/src/main/home/project-settings/InviteList.tsx
  78. 1 1
      dashboard/src/main/home/project-settings/api-tokens/CreateAPITokenForm.tsx
  79. 1 1
      dashboard/src/main/home/project-settings/api-tokens/CustomPolicyForm.tsx
  80. 1 1
      dashboard/src/main/home/project-settings/api-tokens/TokenList.tsx
  81. 63 25
      dashboard/src/shared/api.tsx
  82. 3 0
      dashboard/src/shared/routing.tsx
  83. 13 0
      dashboard/src/shared/search.ts
  84. 4 0
      dashboard/src/shared/types.tsx
  85. 27 0
      internal/models/environment.go

+ 2 - 1
api/server/handlers/environment/create.go

@@ -70,9 +70,10 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		Name:                request.Name,
 		GitRepoOwner:        owner,
 		GitRepoName:         name,
+		GitRepoBranches:     strings.Join(request.GitRepoBranches, ","),
 		Mode:                request.Mode,
 		WebhookID:           string(webhookUID),
-		NewCommentsDisabled: false,
+		NewCommentsDisabled: request.DisableNewComments,
 	}
 
 	if len(request.NamespaceAnnotations) > 0 {

+ 21 - 0
api/server/handlers/environment/enable_pull_request.go

@@ -55,6 +55,27 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	envType := env.ToEnvironmentType()
+
+	if len(envType.GitRepoBranches) > 0 {
+		found := false
+
+		for _, branch := range env.ToEnvironmentType().GitRepoBranches {
+			if branch == request.BranchInto {
+				found = true
+				break
+			}
+		}
+
+		if !found {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("base branch '%s' is not enabled for this preview environment, please enable it in the settings page",
+					request.BranchInto), http.StatusBadRequest,
+			))
+			return
+		}
+	}
+
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 	if err != nil {

+ 12 - 0
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -235,6 +235,12 @@ func fetchOpenPullRequests(
 	env *models.Environment,
 	deplInfoMap map[string]bool,
 ) ([]*types.PullRequest, error) {
+	branchesMap := make(map[string]bool)
+
+	for _, br := range env.ToEnvironmentType().GitRepoBranches {
+		branchesMap[br] = true
+	}
+
 	openPRs, resp, err := client.PullRequests.List(ctx, env.GitRepoOwner, env.GitRepoName,
 		&github.PullRequestListOptions{
 			ListOptions: github.ListOptions{
@@ -269,6 +275,12 @@ func fetchOpenPullRequests(
 	}
 
 	for _, pr := range openPRs {
+		if len(branchesMap) > 0 {
+			if _, ok := branchesMap[pr.GetBase().GetRef()]; !ok {
+				continue
+			}
+		}
+
 		if _, ok := deplInfoMap[fmt.Sprintf("%s-%s-%d", env.GitRepoOwner, env.GitRepoName, pr.GetNumber())]; !ok {
 			prs = append(prs, &types.PullRequest{
 				Title:      pr.GetTitle(),

+ 118 - 0
api/server/handlers/environment/update_environment_settings.go

@@ -0,0 +1,118 @@
+package environment
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"reflect"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type UpdateEnvironmentSettingsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateEnvironmentSettingsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateEnvironmentSettingsHandler {
+	return &UpdateEnvironmentSettingsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	envID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	request := &types.UpdateEnvironmentSettingsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such environment with ID: %d", envID)))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var newBranches []string
+
+	for _, br := range request.GitRepoBranches {
+		name := strings.TrimSpace(br)
+
+		if len(name) > 0 {
+			newBranches = append(newBranches, name)
+		}
+	}
+
+	changed := !reflect.DeepEqual(env.ToEnvironmentType().GitRepoBranches, newBranches)
+
+	if changed {
+		env.GitRepoBranches = strings.Join(request.GitRepoBranches, ",")
+	}
+
+	if request.DisableNewComments != env.NewCommentsDisabled {
+		env.NewCommentsDisabled = request.DisableNewComments
+		changed = true
+	}
+
+	if request.Mode != env.Mode {
+		env.Mode = request.Mode
+		changed = true
+	}
+
+	if len(request.NamespaceAnnotations) > 0 {
+		var annotations []string
+
+		for k, v := range request.NamespaceAnnotations {
+			annotations = append(annotations, fmt.Sprintf("%s=%s", k, v))
+		}
+
+		env.NamespaceAnnotations = []byte(strings.Join(annotations, ","))
+
+		changed = true
+	} else {
+		env.NamespaceAnnotations = []byte{}
+
+		changed = true
+	}
+
+	if changed {
+		env, err = c.Repo().Environment().UpdateEnvironment(env)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	c.WriteResult(w, r, env.ToEnvironmentType())
+}

+ 17 - 0
api/server/handlers/webhook/github_incoming.go

@@ -88,6 +88,23 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 			webhookID, owner, repo, err)
 	}
 
+	envType := env.ToEnvironmentType()
+
+	if len(envType.GitRepoBranches) > 0 {
+		found := false
+
+		for _, br := range envType.GitRepoBranches {
+			if br == event.GetPullRequest().GetHead().GetRef() {
+				found = true
+				break
+			}
+		}
+
+		if !found {
+			return nil
+		}
+	}
+
 	// create deployment on GitHub API
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 

+ 30 - 0
api/server/router/cluster.go

@@ -589,6 +589,36 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
+		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/environments/{environment_id}/settings ->
+		// environment.NewUpdateEnvironmentSettingsHandler
+		updateEnvironmentSettingsEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPatch,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/environments/{environment_id}/settings",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		updateEnvironmentSettingsHandler := environment.NewUpdateEnvironmentSettingsHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: updateEnvironmentSettingsEndpoint,
+			Handler:  updateEnvironmentSettingsHandler,
+			Router:   r,
+		})
+
 	}
 
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewClusterListNamespacesHandler

+ 16 - 6
api/types/environment.go

@@ -3,12 +3,13 @@ package types
 import "time"
 
 type Environment struct {
-	ID                uint   `json:"id"`
-	ProjectID         uint   `json:"project_id"`
-	ClusterID         uint   `json:"cluster_id"`
-	GitInstallationID uint   `json:"git_installation_id"`
-	GitRepoOwner      string `json:"git_repo_owner"`
-	GitRepoName       string `json:"git_repo_name"`
+	ID                uint     `json:"id"`
+	ProjectID         uint     `json:"project_id"`
+	ClusterID         uint     `json:"cluster_id"`
+	GitInstallationID uint     `json:"git_installation_id"`
+	GitRepoOwner      string   `json:"git_repo_owner"`
+	GitRepoName       string   `json:"git_repo_name"`
+	GitRepoBranches   []string `json:"git_repo_branches"`
 
 	Name                 string            `json:"name"`
 	Mode                 string            `json:"mode"`
@@ -21,6 +22,8 @@ type Environment struct {
 type CreateEnvironmentRequest struct {
 	Name                 string            `json:"name" form:"required"`
 	Mode                 string            `json:"mode" form:"oneof=auto manual" default:"manual"`
+	DisableNewComments   bool              `json:"disable_new_comments"`
+	GitRepoBranches      []string          `json:"git_repo_branches"`
 	NamespaceAnnotations map[string]string `json:"namespace_annotations"`
 }
 
@@ -155,3 +158,10 @@ type ValidatePorterYAMLRequest struct {
 type ValidatePorterYAMLResponse struct {
 	Errors []string `json:"errors"`
 }
+
+type UpdateEnvironmentSettingsRequest struct {
+	Mode                 string            `json:"mode" form:"oneof=auto manual"`
+	DisableNewComments   bool              `json:"disable_new_comments"`
+	GitRepoBranches      []string          `json:"git_repo_branches"`
+	NamespaceAnnotations map[string]string `json:"namespace_annotations"`
+}

+ 68 - 0
dashboard/package-lock.json

@@ -1526,6 +1526,38 @@
       "integrity": "sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw==",
       "dev": true
     },
+    "@tanstack/match-sorter-utils": {
+      "version": "8.5.14",
+      "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.5.14.tgz",
+      "integrity": "sha512-lVNhzTcOJ2bZ4IU+PeCPQ36vowBHvviJb2ZfdRFX5uhy7G0jM8N34zAMbmS5ZmVH8D2B7oU82OWo0e/5ZFzQrw==",
+      "requires": {
+        "remove-accents": "0.4.2"
+      }
+    },
+    "@tanstack/query-core": {
+      "version": "4.13.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.13.0.tgz",
+      "integrity": "sha512-PzmLQcEgC4rl2OzkiPHYPC9O79DFcMGaKsOzDEP+U4PJ+tbkcEP+Z+FQDlfvX8mCwYC7UNH7hXrQ5EdkGlJjVg=="
+    },
+    "@tanstack/react-query": {
+      "version": "4.13.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.13.0.tgz",
+      "integrity": "sha512-dI/5hJ/pGQ74P5hxBLC9h6K0/Cap2T3k0ZjjjFLBCNnohDYgl7LNmMopzrRzBHk2mMjf2hgXHIzcKNG8GOZ5hg==",
+      "requires": {
+        "@tanstack/query-core": "4.13.0",
+        "use-sync-external-store": "^1.2.0"
+      }
+    },
+    "@tanstack/react-query-devtools": {
+      "version": "4.13.5",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.13.5.tgz",
+      "integrity": "sha512-ItXysFt7a2WJcodeoZ52/NFoMeWUgwzSaKL3NXhHvdyDOFsX945/AN/+Q9WgXRDG+YgqDJm6f/ozYIfFaORoZQ==",
+      "requires": {
+        "@tanstack/match-sorter-utils": "^8.1.1",
+        "superjson": "^1.10.0",
+        "use-sync-external-store": "^1.2.0"
+      }
+    },
     "@testing-library/dom": {
       "version": "6.16.0",
       "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-6.16.0.tgz",
@@ -3884,6 +3916,14 @@
       "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=",
       "dev": true
     },
+    "copy-anything": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.2.tgz",
+      "integrity": "sha512-CzATjGXzUQ0EvuvgOCI6A4BGOo2bcVx8B+eC2nF862iv9fopnPQwlrbACakNCHRIJbCSBj+J/9JeDf60k64MkA==",
+      "requires": {
+        "is-what": "^4.1.6"
+      }
+    },
     "copy-concurrently": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
@@ -5433,6 +5473,11 @@
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
       "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
     },
+    "fuse.js": {
+      "version": "6.6.2",
+      "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz",
+      "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA=="
+    },
     "gensync": {
       "version": "1.0.0-beta.2",
       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -6313,6 +6358,11 @@
         "call-bind": "^1.0.0"
       }
     },
+    "is-what": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.7.tgz",
+      "integrity": "sha512-DBVOQNiPKnGMxRMLIYSwERAS5MVY1B7xYiGnpgctsOFvVDz9f9PFXXxMcTOHuoqYp4NK9qFYQaIC1NRRxLMpBQ=="
+    },
     "is-windows": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
@@ -8400,6 +8450,11 @@
       "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=",
       "dev": true
     },
+    "remove-accents": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
+      "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA=="
+    },
     "remove-trailing-separator": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@@ -9557,6 +9612,14 @@
         "supports-color": "^5.5.0"
       }
     },
+    "superjson": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.11.0.tgz",
+      "integrity": "sha512-6PfAg1FKhqkwWvPb2uXhH4MkMttdc17eJ91+Aoz4s1XUEDZFmLfFx/xVA3wgkPxAGy5dpozgGdK6V/n20Wj9yg==",
+      "requires": {
+        "copy-anything": "^3.0.2"
+      }
+    },
     "supports-color": {
       "version": "5.5.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -10105,6 +10168,11 @@
       "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
       "dev": true
     },
+    "use-sync-external-store": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
+      "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA=="
+    },
     "util": {
       "version": "0.11.1",
       "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",

+ 3 - 0
dashboard/package.json

@@ -9,6 +9,8 @@
     "@material-ui/lab": "^4.0.0-alpha.61",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
+    "@tanstack/react-query": "^4.13.0",
+    "@tanstack/react-query-devtools": "^4.13.5",
     "@visx/axis": "^1.6.1",
     "@visx/curve": "^1.0.0",
     "@visx/event": "^1.3.0",
@@ -35,6 +37,7 @@
     "d3-time-format": "^3.0.0",
     "dayjs": "^1.11.5",
     "dotenv": "^8.2.0",
+    "fuse.js": "^6.6.2",
     "highlight.run": "^1.4.5",
     "ini": ">=1.3.6",
     "js-base64": "^3.6.0",

+ 13 - 8
dashboard/src/App.tsx

@@ -2,20 +2,25 @@ import React, { Component } from "react";
 import { BrowserRouter } from "react-router-dom";
 import PorterErrorBoundary from "shared/error_handling/PorterErrorBoundary";
 import styled, { createGlobalStyle } from "styled-components";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 
 import MainWrapper from "./main/MainWrapper";
 
+const queryClient = new QueryClient();
+
 export default class App extends Component {
   render() {
     return (
-      <StyledMain>
-        <GlobalStyle />
-        <PorterErrorBoundary errorBoundaryLocation="globalErrorBoundary">
-          <BrowserRouter>
-            <MainWrapper />
-          </BrowserRouter>
-        </PorterErrorBoundary>
-      </StyledMain>
+      <QueryClientProvider client={queryClient}>
+        <StyledMain>
+          <GlobalStyle />
+          <PorterErrorBoundary errorBoundaryLocation="globalErrorBoundary">
+            <BrowserRouter>
+              <MainWrapper />
+            </BrowserRouter>
+          </PorterErrorBoundary>
+        </StyledMain>
+      </QueryClientProvider>
     );
   }
 }

+ 7 - 1
dashboard/src/components/DocsHelper.tsx

@@ -39,7 +39,7 @@ const DocsHelper: React.FC<Props> = ({
       >
         <div>
           <HelperButton onClick={handleTooltipToggle}>
-            <i className="material-icons">help_outline</i>
+            <Icon className="material-icons">help_outline</Icon>
           </HelperButton>
           {open && (
             <Tooltip placement={placement}>
@@ -167,3 +167,9 @@ const DocsHelperContainer = styled.div<{ disableMargin: boolean }>`
   }}
   position: relative;
 `;
+
+const Icon = styled.i`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;

+ 34 - 0
dashboard/src/components/OldPlaceholder.tsx

@@ -0,0 +1,34 @@
+import React from "react";
+import styled from "styled-components";
+
+interface Props {
+  height?: string;
+  minHeight?: string;
+  children: React.ReactNode;
+}
+
+const OldPlaceholder: React.FC<Props> = ({ height, minHeight, children }) => {
+  return (
+    <StyledPlaceholder height={height} minHeight={minHeight}>
+      {children}
+    </StyledPlaceholder>
+  );
+};
+
+export default OldPlaceholder;
+
+const StyledPlaceholder = styled.div<{
+  height: string;
+  minHeight: string;
+}>`
+  width: 100%;
+  height: ${(props) => props.height || "100px"};
+  minheight: ${(props) => props.minHeight || ""};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: #ffffff44;
+  border-radius: 5px;
+  background: #ffffff11;
+`;

+ 426 - 0
dashboard/src/components/OldTable.tsx

@@ -0,0 +1,426 @@
+import React, { useEffect } from "react";
+import styled from "styled-components";
+import {
+  Column,
+  Row,
+  useGlobalFilter,
+  usePagination,
+  useTable,
+} from "react-table";
+import Loading from "components/Loading";
+import Selector from "./Selector";
+import loading from "assets/loading.gif";
+
+const GlobalFilter: React.FunctionComponent<any> = ({
+  setGlobalFilter,
+  onRefresh,
+  isRefreshing,
+}) => {
+  const [value, setValue] = React.useState("");
+  const onChange = (value: string) => {
+    setValue(value);
+    setGlobalFilter(value || undefined);
+  };
+
+  return (
+    <SearchRowWrapper>
+      <SearchRow>
+        <i className="material-icons">search</i>
+        <SearchInput
+          value={value}
+          onChange={(e: any) => {
+            onChange(e.target.value);
+          }}
+          placeholder="Search"
+        />
+      </SearchRow>
+      {typeof onRefresh === "function" && (
+        <RefreshButton onClick={onRefresh} disabled={isRefreshing}>
+          {isRefreshing ? (
+            <>
+              <img src={loading} alt="loading icon" />
+            </>
+          ) : (
+            <i className="material-icons">refresh</i>
+          )}
+        </RefreshButton>
+      )}
+    </SearchRowWrapper>
+  );
+};
+
+export type TableProps = {
+  columns: Column<any>[];
+  data: any[];
+  onRowClick?: (row: Row) => void;
+  isLoading: boolean;
+  disableGlobalFilter?: boolean;
+  disableHover?: boolean;
+  enablePagination?: boolean;
+  hasError?: boolean;
+  errorMessage?: string;
+  onRefresh?: () => void;
+  isRefreshing?: boolean;
+};
+
+const MIN_PAGE_SIZE = 1;
+
+const Table: React.FC<TableProps> = ({
+  columns: columnsData,
+  data,
+  onRowClick,
+  isLoading,
+  disableGlobalFilter = false,
+  disableHover,
+  enablePagination,
+  hasError,
+  errorMessage = "An unexpected error occurred, please try again.",
+  onRefresh,
+  isRefreshing = false,
+}) => {
+  const {
+    getTableProps,
+    getTableBodyProps,
+    page,
+    setGlobalFilter,
+    prepareRow,
+    headerGroups,
+    visibleColumns,
+
+    // Pagination options
+    canPreviousPage,
+    canNextPage,
+    pageOptions,
+    pageCount,
+    gotoPage,
+    nextPage,
+    previousPage,
+    setPageSize,
+    state: { pageIndex, pageSize },
+  } = useTable(
+    {
+      columns: columnsData,
+      data,
+    },
+    useGlobalFilter,
+    usePagination
+  );
+
+  useEffect(() => {
+    if (!enablePagination) {
+      setPageSize(data.length || MIN_PAGE_SIZE);
+    }
+  }, [data, enablePagination]);
+
+  const renderRows = () => {
+    if (hasError) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            {errorMessage}
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
+    if (isLoading) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} height="150px">
+            <Loading />
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
+    if (!page.length) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            No data available
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+    return (
+      <>
+        {page.map((row) => {
+          prepareRow(row);
+
+          return (
+            <StyledTr
+              disableHover={disableHover}
+              {...row.getRowProps()}
+              enablePointer={!!onRowClick}
+              onClick={() => onRowClick && onRowClick(row)}
+              selected={false}
+            >
+              {/* TODO: This is actually broken, not sure why but we need the width to be properly setted, this is a temporary solution */}
+              {row.cells.map((cell) => {
+                return (
+                  <StyledTd
+                    {...cell.getCellProps()}
+                    style={{
+                      width: cell.column.totalWidth,
+                    }}
+                  >
+                    {cell.render("Cell")}
+                  </StyledTd>
+                );
+              })}
+            </StyledTr>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <TableWrapper>
+      {!disableGlobalFilter && (
+        <GlobalFilter
+          setGlobalFilter={setGlobalFilter}
+          onRefresh={onRefresh}
+          isRefreshing={isRefreshing}
+        />
+      )}
+      <StyledTable {...getTableProps()}>
+        <StyledTHead>
+          {headerGroups.map((headerGroup) => (
+            <StyledTr
+              {...headerGroup.getHeaderGroupProps()}
+              disableHover={true}
+            >
+              {headerGroup.headers.map((column) => (
+                <StyledTh {...column.getHeaderProps()}>
+                  {column.render("Header")}
+                </StyledTh>
+              ))}
+            </StyledTr>
+          ))}
+        </StyledTHead>
+        <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
+      </StyledTable>
+      {enablePagination && (
+        <FlexEnd style={{ marginTop: "15px" }}>
+          <PageCountWrapper>
+            Page size:
+            <Selector
+              activeValue={String(pageSize)}
+              options={[
+                {
+                  label: "10",
+                  value: "10",
+                },
+                {
+                  label: "20",
+                  value: "20",
+                },
+                {
+                  label: "50",
+                  value: "50",
+                },
+                {
+                  label: "100",
+                  value: "100",
+                },
+              ]}
+              setActiveValue={(val) => setPageSize(Number(val))}
+              width="70px"
+            ></Selector>
+          </PageCountWrapper>
+          <PaginationActionsWrapper>
+            <PaginationAction
+              disabled={!canPreviousPage}
+              onClick={previousPage}
+            >
+              {"<"}
+            </PaginationAction>
+            <PageCounter>
+              {pageIndex + 1} of {pageCount}
+            </PageCounter>
+            <PaginationAction disabled={!canNextPage} onClick={nextPage}>
+              {">"}
+            </PaginationAction>
+          </PaginationActionsWrapper>
+        </FlexEnd>
+      )}
+    </TableWrapper>
+  );
+};
+
+export default Table;
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
+const FlexEnd = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  width: 100%;
+`;
+
+const PaginationActionsWrapper = styled.div``;
+
+const PageCountWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-width: 160px;
+  margin-right: 10px;
+`;
+
+const PaginationAction = styled.button`
+  border: none;
+  background: unset;
+  color: white;
+  padding: 10px;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    background: #ffffff40;
+  }
+
+  :disabled {
+    color: #ffffff88;
+    cursor: unset;
+    :hover {
+      background: unset;
+    }
+  }
+`;
+
+const PageCounter = styled.span`
+  margin: 0 5px;
+`;
+
+type StyledTrProps = {
+  enablePointer?: boolean;
+  disableHover?: boolean;
+  selected?: boolean;
+};
+
+export const StyledTr = styled.tr`
+  line-height: 2.2em;
+  background: ${(props: StyledTrProps) => (props.selected ? "#ffffff11" : "")};
+  :hover {
+    background: ${(props: StyledTrProps) =>
+      props.disableHover ? "" : "#ffffff22"};
+  }
+  cursor: ${(props: StyledTrProps) =>
+    props.enablePointer ? "pointer" : "unset"};
+`;
+
+export const StyledTd = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  :first-child {
+    padding-left: 10px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+  user-select: text;
+
+  ${(props: { align?: "center" | "left" }) => {
+    if (props.align) {
+      return `text-align:${props.align};`;
+    }
+  }}
+`;
+
+export const StyledTHead = styled.thead`
+  width: 100%;
+  border-top: 1px solid #aaaabb22;
+  border-bottom: 1px solid #aaaabb22;
+  position: sticky;
+`;
+
+export const StyledTh = styled.th`
+  text-align: left;
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  :first-child {
+    padding-left: 10px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+`;
+
+export const StyledTable = styled.table`
+  width: 100%;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  padding: 0;
+  height: 21px;
+`;
+
+const SearchRow = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  color: #ffffff55;
+  border-radius: 4px;
+  user-select: none;
+  align-items: center;
+  padding: 7px 0px;
+  min-width: 300px;
+  max-width: min-content;
+  background: #ffffff11;
+
+  i {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 18px;
+  }
+`;
+
+const SearchRowWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 15px;
+  margin-top: 0px;
+`;
+
+const RefreshButton = styled.button`
+  justify-self: flex-end;
+  border: 1px solid #ffffff00;
+  border-radius: 50%;
+  background: inherit;
+  color: #ffffff;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 35px;
+  height: 35px;
+
+  > i {
+    font-size: 20px;
+  }
+  > img {
+    width: 20px;
+    height: 20px;
+  }
+
+  :hover {
+    color: #ffffff88;
+    border-color: #ffffff88;
+  }
+`;

+ 40 - 5
dashboard/src/components/Placeholder.tsx

@@ -5,30 +5,65 @@ interface Props {
   height?: string;
   minHeight?: string;
   children: React.ReactNode;
+  title?: string;
 }
 
-const Placeholder: React.FC<Props> = ({ height, minHeight, children }) => {
+const Placeholder: React.FC<Props> = ({ 
+  height, 
+  minHeight, 
+  children,
+  title,
+}) => {
   return (
     <StyledPlaceholder height={height} minHeight={minHeight}>
-      {children}
+      <Wrapper>
+        <Title>{title}</Title>
+        <Flex>{children}</Flex>
+      </Wrapper>
     </StyledPlaceholder>
   );
 };
 
 export default Placeholder;
 
+const Flex = styled.div`
+  display: flex;
+  margin-top: 10px;
+  align-items: center;
+`;
+
+const Wrapper = styled.div`
+  margin-bottom: 10px;
+`;
+
+const Title = styled.div`
+  font-size: 16px;
+  color: white;
+  font-weight: 500;
+`;
+
 const StyledPlaceholder = styled.div<{
   height: string;
   minHeight: string;
 }>`
   width: 100%;
   height: ${(props) => props.height || "100px"};
-  minheight: ${(props) => props.minHeight || ""};
+  min-height: ${(props) => props.minHeight || ""};
   display: flex;
   align-items: center;
+  color: #8D949E;
+  padding: 50px;
   justify-content: center;
   font-size: 13px;
-  color: #ffffff44;
   border-radius: 5px;
-  background: #ffffff11;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  padding-bottom: 60px;
+
+  > div {
+    > i {
+      font-size: 16px;
+      margin-right: 12px;
+    }
+  }
 `;

+ 1 - 1
dashboard/src/components/ProvisionerStatus.tsx

@@ -12,7 +12,7 @@ import {
   TFState,
 } from "shared/types";
 import api from "shared/api";
-import Placeholder from "./Placeholder";
+import Placeholder from "./OldPlaceholder";
 import Loading from "./Loading";
 import { Context } from "shared/Context";
 import { useWebsockets } from "shared/hooks/useWebsockets";

+ 30 - 11
dashboard/src/components/SearchSelector.tsx

@@ -1,22 +1,25 @@
 import _ from "lodash";
 import React, { useMemo, useState } from "react";
 import styled from "styled-components";
+import Loading from "./Loading";
 
-type Props = {
-  options: any[];
-  onSelect: (option: any) => void;
+type Props<T = any> = {
+  options: T[];
+  onSelect: (option: T) => void;
   label?: string;
   dropdownLabel?: string;
-  getOptionLabel?: (option: any) => string;
-  filterBy?: ((option: any) => string) | string;
+  getOptionLabel?: (option: T) => string;
+  filterBy?: ((option: T) => string) | string;
   noOptionsText?: string;
   dropdownMaxHeight?: string;
   renderAddButton?: any;
   className?: string;
-  renderOptionIcon?: (option: any) => React.ReactNode;
+  renderOptionIcon?: (option: T) => React.ReactNode;
+  placeholder?: string;
+  showLoading?: boolean;
 };
 
-const SearchSelector = ({
+function SearchSelector<O = any>({
   options,
   onSelect,
   label,
@@ -28,7 +31,9 @@ const SearchSelector = ({
   renderAddButton,
   className,
   renderOptionIcon,
-}: Props) => {
+  placeholder = "Find or add a tag...", // legacy value to not break existing code
+  showLoading = false,
+}: Props<O>) {
   const [isExpanded, setIsExpanded] = useState(false);
   const [filter, setFilter] = useState("");
 
@@ -57,9 +62,22 @@ const SearchSelector = ({
       );
     }
 
-    return options.filter((option) => option.includes(filter));
+    return options.filter((option) =>
+      typeof option === "string" ? option.includes(filter) : true
+    );
   }, [filter, options]);
 
+  if (showLoading) {
+    return (
+      <>
+        {label?.length ? <Label>{label}</Label> : null}
+        <InputWrapper className={className}>
+          <Loading />
+        </InputWrapper>
+      </>
+    );
+  }
+
   return (
     <>
       {label?.length ? <Label>{label}</Label> : null}
@@ -71,7 +89,7 @@ const SearchSelector = ({
       >
         <Input
           value={filter}
-          placeholder="Find or add a tag..."
+          placeholder={placeholder}
           onClick={(e) => {
             setIsExpanded(false);
             e.stopPropagation();
@@ -139,7 +157,7 @@ const SearchSelector = ({
       </InputWrapper>
     </>
   );
-};
+}
 
 export default SearchSelector;
 
@@ -152,6 +170,7 @@ const InputWrapper = styled.div`
   background: #ffffff11;
   position: relative;
   width: 100%;
+  min-height: 37px;
 `;
 
 const Input = styled.input`

+ 25 - 350
dashboard/src/components/Table.tsx

@@ -1,5 +1,5 @@
-import React, { useEffect } from "react";
-import styled from "styled-components";
+import Placeholder from "components/Placeholder";
+import React from "react";
 import {
   Column,
   Row,
@@ -7,96 +7,37 @@ import {
   usePagination,
   useTable,
 } from "react-table";
-import Loading from "components/Loading";
-import Selector from "./Selector";
-import loading from "assets/loading.gif";
-
-const GlobalFilter: React.FunctionComponent<any> = ({
-  setGlobalFilter,
-  onRefresh,
-  isRefreshing,
-}) => {
-  const [value, setValue] = React.useState("");
-  const onChange = (value: string) => {
-    setValue(value);
-    setGlobalFilter(value || undefined);
-  };
-
-  return (
-    <SearchRowWrapper>
-      <SearchRow>
-        <i className="material-icons">search</i>
-        <SearchInput
-          value={value}
-          onChange={(e: any) => {
-            onChange(e.target.value);
-          }}
-          placeholder="Search"
-        />
-      </SearchRow>
-      {typeof onRefresh === "function" && (
-        <RefreshButton onClick={onRefresh} disabled={isRefreshing}>
-          {isRefreshing ? (
-            <>
-              <img src={loading} alt="loading icon" />
-            </>
-          ) : (
-            <i className="material-icons">refresh</i>
-          )}
-        </RefreshButton>
-      )}
-    </SearchRowWrapper>
-  );
-};
+import {
+  StyledTd,
+  StyledTable,
+  StyledTHead,
+  StyledTh,
+  StyledTBody,
+} from "../main/home/cluster-dashboard/expanded-chart/events/styles";
 
 export type TableProps = {
   columns: Column<any>[];
   data: any[];
   onRowClick?: (row: Row) => void;
-  isLoading: boolean;
-  disableGlobalFilter?: boolean;
-  disableHover?: boolean;
-  enablePagination?: boolean;
-  hasError?: boolean;
-  errorMessage?: string;
-  onRefresh?: () => void;
-  isRefreshing?: boolean;
+  placeholder?: string;
 };
 
-const MIN_PAGE_SIZE = 1;
-
 const Table: React.FC<TableProps> = ({
   columns: columnsData,
   data,
   onRowClick,
-  isLoading,
-  disableGlobalFilter = false,
-  disableHover,
-  enablePagination,
-  hasError,
-  errorMessage = "An unexpected error occurred, please try again.",
-  onRefresh,
-  isRefreshing = false,
+  placeholder,
 }) => {
+  if (!data || data.length == 0) {
+    return <Placeholder>{placeholder}</Placeholder>;
+  }
+
   const {
+    rows,
     getTableProps,
     getTableBodyProps,
-    page,
-    setGlobalFilter,
     prepareRow,
     headerGroups,
-    visibleColumns,
-
-    // Pagination options
-    canPreviousPage,
-    canNextPage,
-    pageOptions,
-    pageCount,
-    gotoPage,
-    nextPage,
-    previousPage,
-    setPageSize,
-    state: { pageIndex, pageSize },
   } = useTable(
     {
       columns: columnsData,
@@ -106,57 +47,19 @@ const Table: React.FC<TableProps> = ({
     usePagination
   );
 
-  useEffect(() => {
-    if (!enablePagination) {
-      setPageSize(data.length || MIN_PAGE_SIZE);
-    }
-  }, [data, enablePagination]);
-
   const renderRows = () => {
-    if (hasError) {
-      return (
-        <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length} align="center">
-            {errorMessage}
-          </StyledTd>
-        </StyledTr>
-      );
-    }
-
-    if (isLoading) {
-      return (
-        <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length} height="150px">
-            <Loading />
-          </StyledTd>
-        </StyledTr>
-      );
-    }
-
-    if (!page.length) {
-      return (
-        <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length} align="center">
-            No data available
-          </StyledTd>
-        </StyledTr>
-      );
-    }
     return (
       <>
-        {page.map((row) => {
+        {rows.map((row: any) => {
           prepareRow(row);
 
           return (
-            <StyledTr
-              disableHover={disableHover}
+            <tr
               {...row.getRowProps()}
-              enablePointer={!!onRowClick}
               onClick={() => onRowClick && onRowClick(row)}
               selected={false}
             >
-              {/* TODO: This is actually broken, not sure why but we need the width to be properly setted, this is a temporary solution */}
-              {row.cells.map((cell) => {
+              {row.cells.map((cell: any) => {
                 return (
                   <StyledTd
                     {...cell.getCellProps()}
@@ -168,7 +71,7 @@ const Table: React.FC<TableProps> = ({
                   </StyledTd>
                 );
               })}
-            </StyledTr>
+            </tr>
           );
         })}
       </>
@@ -176,251 +79,23 @@ const Table: React.FC<TableProps> = ({
   };
 
   return (
-    <TableWrapper>
-      {!disableGlobalFilter && (
-        <GlobalFilter
-          setGlobalFilter={setGlobalFilter}
-          onRefresh={onRefresh}
-          isRefreshing={isRefreshing}
-        />
-      )}
+    <>
       <StyledTable {...getTableProps()}>
         <StyledTHead>
           {headerGroups.map((headerGroup) => (
-            <StyledTr
-              {...headerGroup.getHeaderGroupProps()}
-              disableHover={true}
-            >
+            <tr {...headerGroup.getHeaderGroupProps()}>
               {headerGroup.headers.map((column) => (
                 <StyledTh {...column.getHeaderProps()}>
                   {column.render("Header")}
                 </StyledTh>
               ))}
-            </StyledTr>
+            </tr>
           ))}
         </StyledTHead>
-        <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
+        <StyledTBody {...getTableBodyProps()}>{renderRows()}</StyledTBody>
       </StyledTable>
-      {enablePagination && (
-        <FlexEnd style={{ marginTop: "15px" }}>
-          <PageCountWrapper>
-            Page size:
-            <Selector
-              activeValue={String(pageSize)}
-              options={[
-                {
-                  label: "10",
-                  value: "10",
-                },
-                {
-                  label: "20",
-                  value: "20",
-                },
-                {
-                  label: "50",
-                  value: "50",
-                },
-                {
-                  label: "100",
-                  value: "100",
-                },
-              ]}
-              setActiveValue={(val) => setPageSize(Number(val))}
-              width="70px"
-            ></Selector>
-          </PageCountWrapper>
-          <PaginationActionsWrapper>
-            <PaginationAction
-              disabled={!canPreviousPage}
-              onClick={previousPage}
-            >
-              {"<"}
-            </PaginationAction>
-            <PageCounter>
-              {pageIndex + 1} of {pageCount}
-            </PageCounter>
-            <PaginationAction disabled={!canNextPage} onClick={nextPage}>
-              {">"}
-            </PaginationAction>
-          </PaginationActionsWrapper>
-        </FlexEnd>
-      )}
-    </TableWrapper>
+    </>
   );
 };
 
 export default Table;
-
-const TableWrapper = styled.div`
-  padding-bottom: 20px;
-`;
-
-const FlexEnd = styled.div`
-  display: flex;
-  justify-content: flex-end;
-  align-items: center;
-  width: 100%;
-`;
-
-const PaginationActionsWrapper = styled.div``;
-
-const PageCountWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  min-width: 160px;
-  margin-right: 10px;
-`;
-
-const PaginationAction = styled.button`
-  border: none;
-  background: unset;
-  color: white;
-  padding: 10px;
-  cursor: pointer;
-  border-radius: 5px;
-  :hover {
-    background: #ffffff40;
-  }
-
-  :disabled {
-    color: #ffffff88;
-    cursor: unset;
-    :hover {
-      background: unset;
-    }
-  }
-`;
-
-const PageCounter = styled.span`
-  margin: 0 5px;
-`;
-
-type StyledTrProps = {
-  enablePointer?: boolean;
-  disableHover?: boolean;
-  selected?: boolean;
-};
-
-export const StyledTr = styled.tr`
-  line-height: 2.2em;
-  background: ${(props: StyledTrProps) => (props.selected ? "#ffffff11" : "")};
-  :hover {
-    background: ${(props: StyledTrProps) =>
-      props.disableHover ? "" : "#ffffff22"};
-  }
-  cursor: ${(props: StyledTrProps) =>
-    props.enablePointer ? "pointer" : "unset"};
-`;
-
-export const StyledTd = styled.td`
-  font-size: 13px;
-  color: #ffffff;
-  :first-child {
-    padding-left: 10px;
-  }
-  :last-child {
-    padding-right: 10px;
-  }
-  user-select: text;
-
-  ${(props: { align?: "center" | "left" }) => {
-    if (props.align) {
-      return `text-align:${props.align};`;
-    }
-  }}
-`;
-
-export const StyledTHead = styled.thead`
-  width: 100%;
-  border-top: 1px solid #aaaabb22;
-  border-bottom: 1px solid #aaaabb22;
-  position: sticky;
-`;
-
-export const StyledTh = styled.th`
-  text-align: left;
-  font-size: 13px;
-  font-weight: 500;
-  color: #aaaabb;
-  :first-child {
-    padding-left: 10px;
-  }
-  :last-child {
-    padding-right: 10px;
-  }
-`;
-
-export const StyledTable = styled.table`
-  width: 100%;
-  min-width: 500px;
-  border-collapse: collapse;
-`;
-
-const SearchInput = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: none;
-  width: 100%;
-  color: white;
-  padding: 0;
-  height: 21px;
-`;
-
-const SearchRow = styled.div`
-  display: flex;
-  width: 100%;
-  font-size: 13px;
-  color: #ffffff55;
-  border-radius: 4px;
-  user-select: none;
-  align-items: center;
-  padding: 7px 0px;
-  min-width: 300px;
-  max-width: min-content;
-  background: #ffffff11;
-
-  i {
-    width: 18px;
-    height: 18px;
-    margin-left: 12px;
-    margin-right: 12px;
-    font-size: 18px;
-  }
-`;
-
-const SearchRowWrapper = styled.div`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-  margin-top: 0px;
-`;
-
-const RefreshButton = styled.button`
-  justify-self: flex-end;
-  border: 1px solid #ffffff00;
-  border-radius: 50%;
-  background: inherit;
-  color: #ffffff;
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 35px;
-  height: 35px;
-
-  > i {
-    font-size: 20px;
-  }
-  > img {
-    width: 20px;
-    height: 20px;
-  }
-
-  :hover {
-    color: #ffffff88;
-    border-color: #ffffff88;
-  }
-`;

+ 16 - 2
dashboard/src/components/TitleSection.tsx

@@ -9,6 +9,7 @@ interface Props {
   className?: string;
   materialIconClass?: string;
   handleNavBack?: () => void;
+  onClick?: any;
 }
 
 const TitleSection: React.FC<Props> = ({
@@ -19,6 +20,7 @@ const TitleSection: React.FC<Props> = ({
   handleNavBack,
   className,
   materialIconClass,
+  onClick,
 }) => {
   return (
     <StyledTitleSection className={className}>
@@ -39,7 +41,12 @@ const TitleSection: React.FC<Props> = ({
           <Icon width={iconWidth} src={icon} />
         ))}
 
-      <StyledTitle capitalize={capitalize}>{children}</StyledTitle>
+      <StyledTitle
+        capitalize={capitalize}
+        onClick={onClick}
+      >
+        {children}
+      </StyledTitle>
     </StyledTitleSection>
   );
 };
@@ -78,13 +85,20 @@ const MaterialIcon = styled.span<{ width: string }>`
   margin-right: 16px;
 `;
 
-const StyledTitle = styled.div<{ capitalize: boolean }>`
+const StyledTitle = styled.div<{ 
+  capitalize: boolean;
+  onClick?: any;
+}>`
   font-size: 21px;
   font-weight: 600;
   user-select: text;
   text-transform: ${(props) => (props.capitalize ? "capitalize" : "")};
   display: flex;
   align-items: center;
+  cursor: ${props => props.onClick ? "pointer" : ""};
+  :hover {
+    text-decoration: ${props => props.onClick ? "underline" : ""};
+  }
 
   > i {
     margin-left: 10px;

+ 16 - 4
dashboard/src/components/form-components/CheckboxRow.tsx

@@ -7,6 +7,9 @@ type PropsType = {
   toggle: () => void;
   isRequired?: boolean;
   disabled?: boolean;
+  wrapperStyles?: {
+    disableMargin?: boolean;
+  };
 };
 
 type StateType = {};
@@ -14,7 +17,9 @@ type StateType = {};
 export default class CheckboxRow extends Component<PropsType, StateType> {
   render() {
     return (
-      <StyledCheckboxRow>
+      <StyledCheckboxRow
+        disableMargin={this.props.wrapperStyles?.disableMargin}
+      >
         <CheckboxWrapper
           disabled={this.props.disabled}
           onClick={!this.props.disabled ? this.props.toggle : undefined}
@@ -65,9 +70,16 @@ const Checkbox = styled.div<{ checked: boolean }>`
   }
 `;
 
-const StyledCheckboxRow = styled.div`
+const StyledCheckboxRow = styled.div<{ disableMargin?: boolean }>`
   display: flex;
   align-items: center;
-  margin-bottom: 15px;
-  margin-top: 20px;
+  ${({ disableMargin }) => {
+    if (disableMargin) {
+      return "";
+    }
+    return `
+      margin-bottom: 15px;
+      margin-top: 20px;
+    `;
+  }}
 `;

+ 1 - 1
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -436,7 +436,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
         )}
         {enableSyncedEnvGroups && !!state.synced_env_groups?.length && (
           <>
-            <Heading>Synced Environment Groups</Heading>
+            <Heading>Synced environment groups</Heading>
             <Br />
             {state.synced_env_groups?.map((envGroup: any) => {
               return (

+ 5 - 4
dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx

@@ -6,11 +6,12 @@ import { Context } from "shared/Context";
 import TitleSection from "components/TitleSection";
 
 type PropsType = {
-  image: any;
-  title: string;
+  image?: any;
+  title: any;
   description?: string;
   materialIconClass?: string;
   disableLineBreak?: boolean;
+  capitalize?: boolean;
 };
 
 type StateType = {};
@@ -20,7 +21,7 @@ export default class DashboardHeader extends Component<PropsType, StateType> {
     return (
       <>
         <TitleSection
-          capitalize={true}
+          capitalize={this.props.capitalize === undefined || this.props.capitalize}
           icon={this.props.image}
           materialIconClass={this.props.materialIconClass}
         >
@@ -88,7 +89,7 @@ const InfoLabel = styled.div`
 `;
 
 const InfoSection = styled.div`
-  margin-top: 20px;
+  margin-top: 15px;
   font-family: "Work Sans", sans-serif;
   margin-left: 0px;
   margin-bottom: 35px;

+ 3 - 23
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -17,6 +17,7 @@ import Chart from "./Chart";
 import Loading from "components/Loading";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import CronParser from "cron-parser";
+import Placeholder from "components/Placeholder";
 
 type Props = {
   currentCluster: ClusterType;
@@ -445,13 +446,13 @@ const ChartList: React.FunctionComponent<Props> = ({
       );
     } else if (isError) {
       return (
-        <Placeholder>
+        <Placeholder height="370px">
           <i className="material-icons">error</i> Error connecting to cluster.
         </Placeholder>
       );
     } else if (filteredCharts?.length === 0) {
       return (
-        <Placeholder>
+        <Placeholder height="370px">
           <i className="material-icons">category</i> No
           {currentView === "jobs" ? ` jobs` : ` charts`} found with the given
           filters.
@@ -486,27 +487,6 @@ const ChartList: React.FunctionComponent<Props> = ({
 
 export default ChartList;
 
-const Placeholder = styled.div`
-  width: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  color: #ffffff44;
-  background: #26282f;
-  border-radius: 5px;
-  height: 370px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  font-size: 13px;
-
-  > i {
-    font-size: 16px;
-    margin-right: 12px;
-  }
-`;
-
 const LoadingWrapper = styled.div`
   padding-top: 100px;
 `;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx

@@ -1,6 +1,6 @@
 import DynamicLink from "components/DynamicLink";
 import Loading from "components/Loading";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
 import { CellProps, Column, Row } from "react-table";
 import api from "shared/api";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx

@@ -5,7 +5,7 @@ import styled from "styled-components";
 import Loading from "components/Loading";
 import settings from "assets/settings.svg";
 import TabSelector from "components/TabSelector";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import ParentSize from "@visx/responsive/lib/components/ParentSize";
 import AreaChart from "../expanded-chart/metrics/AreaChart";
 import {

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx

@@ -1,6 +1,6 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 
-import Table from "components/Table";
+import Table from "components/OldTable";
 import { Column } from "react-table";
 import styled from "styled-components";
 import api from "shared/api";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx

@@ -1,5 +1,5 @@
 import React, { useMemo } from "react";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import { Column } from "react-table";
 import styled from "styled-components";
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx

@@ -1,5 +1,5 @@
 import CopyToClipboard from "components/CopyToClipboard";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import { useRouteMatch } from "react-router";
 import { Link } from "react-router-dom";

+ 2 - 1
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -129,9 +129,10 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
         <>
           <DashboardHeader
             image={sliders}
-            title="Environment Groups"
+            title="Environment groups"
             description="Groups of environment variables for storing secrets and configuration."
             disableLineBreak
+            capitalize={false}
           />
           {this.renderBody()}
         </>

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection.tsx

@@ -2,7 +2,7 @@ import { DeviconsNameList } from "assets/devicons-name-list";
 import Helper from "components/form-components/Helper";
 import SelectRow from "components/form-components/SelectRow";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
 import { differenceBy } from "lodash";
 import React, {

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/DeployStatusSection.tsx

@@ -141,7 +141,7 @@ const DropdownWrapper = styled.div<{
   position: absolute;
   left: ${(props) => (props.dropdownAlignRight ? "" : "0")};
   right: ${(props) => (props.dropdownAlignRight ? "0" : "")};
-  z-index: 5;
+  z-index: 1000;
   top: calc(100% + 7px);
   width: 35%;
   min-width: 400px;

+ 6 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventList.tsx

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useContext } from "react";
 import { CellProps } from "react-table";
 
 import styled from "styled-components";
-import EventTable from "./EventTable";
+import Table from "components/Table";
 import Loading from "components/Loading";
 import danger from "assets/danger.svg";
 import rocket from "assets/rocket.png";
@@ -373,7 +373,11 @@ const EventList: React.FC<Props> = ({ filters, namespace, setLogData }) => {
         </LoadWrapper>
       ) : (
         <TableWrapper>
-          <EventTable columns={columns} data={events} />
+          <Table 
+            columns={columns} 
+            data={events} 
+            placeholder="No events found."
+          />
           <FlexRow>
             <Flex>
               <Button

+ 0 - 99
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventTable.tsx

@@ -1,99 +0,0 @@
-import Placeholder from "components/Placeholder";
-import React from "react";
-import {
-  Column,
-  Row,
-  useGlobalFilter,
-  usePagination,
-  useTable,
-} from "react-table";
-import {
-  StyledTd,
-  StyledTable,
-  StyledTHead,
-  StyledTh,
-  StyledTBody,
-} from "./styles";
-
-export type TableProps = {
-  columns: Column<any>[];
-  data: any[];
-  onRowClick?: (row: Row) => void;
-};
-
-const EventTable: React.FC<TableProps> = ({
-  columns: columnsData,
-  data,
-  onRowClick,
-}) => {
-  if (!data || data.length == 0) {
-    return <Placeholder>No events found.</Placeholder>;
-  }
-
-  const {
-    rows,
-    getTableProps,
-    getTableBodyProps,
-    prepareRow,
-    headerGroups,
-  } = useTable(
-    {
-      columns: columnsData,
-      data,
-    },
-    useGlobalFilter,
-    usePagination
-  );
-
-  const renderRows = () => {
-    return (
-      <>
-        {rows.map((row: any) => {
-          prepareRow(row);
-
-          return (
-            <tr
-              {...row.getRowProps()}
-              onClick={() => onRowClick && onRowClick(row)}
-              selected={false}
-            >
-              {row.cells.map((cell: any) => {
-                return (
-                  <StyledTd
-                    {...cell.getCellProps()}
-                    style={{
-                      width: cell.column.totalWidth,
-                    }}
-                  >
-                    {cell.render("Cell")}
-                  </StyledTd>
-                );
-              })}
-            </tr>
-          );
-        })}
-      </>
-    );
-  };
-
-  return (
-    <>
-      <StyledTable {...getTableProps()}>
-        <StyledTHead>
-          {headerGroups.map((headerGroup) => (
-            <tr {...headerGroup.getHeaderGroupProps()}>
-              {headerGroup.headers.map((column) => (
-                <StyledTh {...column.getHeaderProps()}>
-                  {column.render("Header")}
-                </StyledTh>
-              ))}
-            </tr>
-          ))}
-        </StyledTHead>
-        <StyledTBody {...getTableBodyProps()}>{renderRows()}</StyledTBody>
-      </StyledTable>
-    </>
-  );
-};
-
-export default EventTable;

+ 168 - 20
dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx

@@ -3,9 +3,7 @@ import Heading from "components/form-components/Heading";
 import RepoList from "components/repo-selector/RepoList";
 import SaveButton from "components/SaveButton";
 import DocsHelper from "components/DocsHelper";
-import { ActionConfigType } from "shared/types";
-import TitleSection from "components/TitleSection";
-import { useRouteMatch } from "react-router";
+import { GithubActionConfigType } from "shared/types";
 import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import api from "shared/api";
@@ -15,6 +13,11 @@ import { Environment } from "./types";
 import DashboardHeader from "../DashboardHeader";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import CheckboxRow from "components/form-components/CheckboxRow";
+import BranchFilterSelector from "./components/BranchFilterSelector";
+import Helper from "components/form-components/Helper";
+import NamespaceAnnotations, {
+  KeyValueType,
+} from "./components/NamespaceAnnotations";
 
 const ConnectNewRepo: React.FC = () => {
   const { currentProject, currentCluster, setCurrentError } = useContext(
@@ -30,16 +33,26 @@ const ConnectNewRepo: React.FC = () => {
   const { pushFiltered } = useRouting();
 
   // NOTE: git_repo_id is a misnomer as this actually refers to the github app's installation id.
-  const [actionConfig, setActionConfig] = useState<ActionConfigType>({
+  const [actionConfig, setActionConfig] = useState<GithubActionConfigType>({
     git_repo: null,
     image_repo_uri: null,
     git_branch: null,
     git_repo_id: 0,
+    kind: "github",
   });
 
-  useEffect(() => {}, [repo]);
+  // Branch selector data
+  const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
+  const [availableBranches, setAvailableBranches] = useState<string[]>([]);
+  const [isLoadingBranches, setIsLoadingBranches] = useState(false);
 
-  const { url } = useRouteMatch();
+  // Disable new comments data
+  const [isNewCommentsDisabled, setIsNewCommentsDisabled] = useState(false);
+
+  // Namespace annotations
+  const [namespaceAnnotations, setNamespaceAnnotations] = useState<
+    KeyValueType[]
+  >([]);
 
   useEffect(() => {
     api
@@ -65,15 +78,80 @@ const ConnectNewRepo: React.FC = () => {
       .catch(() => {});
   }, []);
 
+  useEffect(() => {
+    if (!actionConfig.git_repo || !actionConfig.git_repo_id) {
+      return;
+    }
+
+    let isSubscribed = true;
+    const repoName = actionConfig.git_repo.split("/")[1];
+    const repoOwner = actionConfig.git_repo.split("/")[0];
+    setIsLoadingBranches(true);
+    api
+      .getBranches<string[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          kind: "github",
+          name: repoName,
+          owner: repoOwner,
+          git_repo_id: actionConfig.git_repo_id,
+        }
+      )
+      .then(({ data }) => {
+        if (isSubscribed) {
+          setIsLoadingBranches(false);
+          setAvailableBranches(data);
+        }
+      })
+      .catch(() => {
+        if (isSubscribed) {
+          setIsLoadingBranches(false);
+          setCurrentError(
+            "Couldn't load branches for this repository, using all branches by default."
+          );
+        }
+      });
+  }, [actionConfig]);
+
   const addRepo = () => {
     let [owner, repoName] = repo.split("/");
+    let annotations: Record<string, string> = {};
+
     setStatus("loading");
+
+    namespaceAnnotations
+      .filter((elem: KeyValueType, index: number, self: KeyValueType[]) => {
+        // remove any collisions that are duplicates
+        let numCollisions = self.reduce((n, _elem: KeyValueType) => {
+          return n + (_elem.key === elem.key ? 1 : 0);
+        }, 0);
+
+        if (numCollisions == 1) {
+          return true;
+        } else {
+          return (
+            index ===
+            self.findIndex((_elem: KeyValueType) => _elem.key === elem.key)
+          );
+        }
+      })
+      .forEach((elem: KeyValueType) => {
+        if (elem.key !== "" && elem.value !== "") {
+          annotations[elem.key] = elem.value;
+        }
+      });
+
     api
       .createEnvironment(
         "<token>",
         {
           name: `preview`,
           mode: enableAutomaticDeployments ? "auto" : "manual",
+          disable_new_comments: isNewCommentsDisabled,
+          git_repo_branches: selectedBranches,
+          namespace_annotations: annotations,
         },
         {
           project_id: currentProject.id,
@@ -98,7 +176,8 @@ const ConnectNewRepo: React.FC = () => {
     <>
       <DashboardHeader
         image={PullRequestIcon}
-        title="Preview Environments"
+        title="Preview environments"
+        capitalize={false}
         description="Create full-stack preview environments for your pull requests."
       />
 
@@ -107,14 +186,28 @@ const ConnectNewRepo: React.FC = () => {
           <i className="material-icons">keyboard_backspace</i>
           Back
         </Button>
-        <Title>Enable Preview Environments on a Repository</Title>
+        <Title>
+          <div
+            style={{
+              display: "flex",
+              alignItems: "center",
+            }}
+          >
+            Enable Preview Environments on a Repository
+            <DocsHelper
+              tooltipText="Learn more about preview environments"
+              link="https://docs.porter.run/preview-environments/overview/"
+              placement="top-end"
+            />
+          </div>
+        </Title>
       </HeaderSection>
 
       <Heading>Select a Repository</Heading>
       <br />
       <RepoList
         actionConfig={actionConfig}
-        setActionConfig={(a: ActionConfigType) => {
+        setActionConfig={(a: GithubActionConfigType) => {
           setActionConfig(a);
           setRepo(a.git_repo);
         }}
@@ -131,20 +224,69 @@ const ConnectNewRepo: React.FC = () => {
         />
       </HelperContainer>
 
-      <FlexWrap>
+      <Heading>Automatic pull request deployments</Heading>
+      <Helper>
+        If you enable this option, the new pull requests will be automatically
+        deployed.
+      </Helper>
+      <CheckboxWrapper>
         <CheckboxRow
-          label="Enable automatic deployments"
+          label="Enable automatic deploys"
           checked={enableAutomaticDeployments}
-          toggle={() => setEnableAutomaticDeployments((prev) => !prev)}
+          toggle={() =>
+            setEnableAutomaticDeployments(!enableAutomaticDeployments)
+          }
+          wrapperStyles={{
+            disableMargin: true,
+          }}
         />
-        <Div>
-          <DocsHelper
-            disableMargin
-            tooltipText="Automatically create a Preview Environment for each new pull request in the repository. By default, preview environments must be manually created per-PR."
-            placement="top-start"
-          />
-        </Div>
-      </FlexWrap>
+      </CheckboxWrapper>
+
+      <Heading>Disable new comments for new deployments</Heading>
+      <Helper>
+        When enabled new comments will not be created for new deployments.
+        Instead the last comment will be updated.
+      </Helper>
+      <CheckboxWrapper>
+        <CheckboxRow
+          label="Disable new comments for deployments"
+          checked={isNewCommentsDisabled}
+          toggle={() => setIsNewCommentsDisabled(!isNewCommentsDisabled)}
+          wrapperStyles={{
+            disableMargin: true,
+          }}
+        />
+      </CheckboxWrapper>
+
+      <Heading>Select allowed branches</Heading>
+      <Helper>
+        If the pull request has a base branch included in this list, it will be
+        allowed to be deployed.
+        <br />
+        (Leave empty to allow all branches)
+      </Helper>
+      <BranchFilterSelector
+        onChange={setSelectedBranches}
+        options={availableBranches}
+        value={selectedBranches}
+        showLoading={isLoadingBranches}
+      />
+
+      <Heading>Namespace annotations</Heading>
+      <Helper>
+        Custom annotations to be injected into the Kubernetes namespace created
+        for each deployment.
+      </Helper>
+      <NamespaceAnnotations
+        values={namespaceAnnotations}
+        setValues={(x: KeyValueType[]) => {
+          let annotations: KeyValueType[] = [];
+          x.forEach((entry) => {
+            annotations.push({ key: entry.key, value: entry.value });
+          });
+          setNamespaceAnnotations(annotations);
+        }}
+      />
 
       <ActionContainer>
         <SaveButton
@@ -273,3 +415,9 @@ const HeaderSection = styled.div`
     margin-right: 7px;
   }
 `;
+
+const CheckboxWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 20px;
+`;

+ 90 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/BranchFilterSelector.tsx

@@ -0,0 +1,90 @@
+import SearchSelector from "components/SearchSelector";
+import React, { useMemo } from "react";
+import styled from "styled-components";
+
+const BranchFilterSelector = ({
+  value,
+  options,
+  onChange,
+  showLoading,
+}: {
+  value: string[];
+  options: string[];
+  onChange: (value: string[]) => void;
+  showLoading?: boolean;
+}) => {
+  const filteredBranches = useMemo(() => {
+    if (!options.length) {
+      return [];
+    }
+
+    if (value.find((branch) => branch === "")) {
+      return options;
+    }
+
+    return options.filter((branch) => !value.includes(branch));
+  }, [options, value]);
+
+  const handleAddBranch = (branch: string) => {
+    const newSelectedBranches = [...value, branch];
+
+    onChange(newSelectedBranches);
+  };
+
+  const handleDeleteBranch = (branch: string) => {
+    const newSelectedBranches = value.filter(
+      (selectedBranch) => selectedBranch !== branch
+    );
+
+    onChange(newSelectedBranches);
+  };
+
+  const placeholder = options?.length
+    ? "Find or add a branch..."
+    : "No branches found for current repository.";
+
+  return (
+    <>
+      <SearchSelector
+        options={filteredBranches}
+        onSelect={(newBranch) => handleAddBranch(newBranch)}
+        getOptionLabel={(option) => option}
+        placeholder={placeholder}
+        showLoading={showLoading}
+      />
+      {/* List selected branches  */}
+
+      <BranchRowList>
+      {value.map((branch) => (
+        <BranchRow key={branch}>
+          <div>{branch}</div>
+          <RemoveBranchButton onClick={() => handleDeleteBranch(branch)}>
+            x
+          </RemoveBranchButton>
+        </BranchRow>
+      ))}
+      </BranchRowList>
+    </>
+  );
+};
+
+export default BranchFilterSelector;
+
+const BranchRowList = styled.div`
+  margin-block: 15px;
+  max-height: 200px;
+  overflow-y: auto;
+`;
+
+const BranchRow = styled.div`
+  padding-inline: 8px;
+  gap: 10px;
+  width: 100%;
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+`;
+
+const RemoveBranchButton = styled.div`
+  cursor: pointer;
+`;

+ 1 - 2
dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx

@@ -134,6 +134,7 @@ const Button = styled(DynamicLink)`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 
@@ -166,6 +167,4 @@ const Button = styled(DynamicLink)`
 `;
 
 const Container = styled.div`
-  width: 50%;
-  display: flex;
 `;

+ 164 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/NamespaceAnnotations.tsx

@@ -0,0 +1,164 @@
+import React, { useEffect } from "react";
+import styled from "styled-components";
+
+export type KeyValueType = {
+  key: string;
+  value: string;
+};
+
+type PropsType = {
+  values: KeyValueType[];
+  setValues: (x: KeyValueType[]) => void;
+};
+
+const NamespaceAnnotations = ({ values, setValues }: PropsType) => {
+  useEffect(() => {
+    if (!values) {
+      setValues([]);
+    }
+  }, [values]);
+
+  if (!values) {
+    return null;
+  }
+
+  return (
+    <>
+      <StyledInputArray>
+        {!!values?.length &&
+          values.map((entry: KeyValueType, i: number) => {
+            return (
+              <InputWrapper key={i}>
+                <Input
+                  placeholder="ex: key"
+                  width="270px"
+                  value={entry.key}
+                  onChange={(e: any) => {
+                    let _values = values;
+                    _values[i].key = e.target.value;
+                    setValues(_values);
+                  }}
+                />
+                <Spacer />
+                <Input
+                  placeholder="ex: value"
+                  width="270px"
+                  value={entry.value}
+                  onChange={(e: any) => {
+                    let _values = values;
+                    _values[i].value = e.target.value;
+                    setValues(_values);
+                  }}
+                />
+                <DeleteButton
+                  onClick={() => {
+                    let _values = values;
+                    _values = _values.filter((val) => val.key !== entry.key);
+                    setValues(_values);
+                  }}
+                >
+                  <i className="material-icons">cancel</i>
+                </DeleteButton>
+              </InputWrapper>
+            );
+          })}
+        <InputWrapper>
+          <AddRowButton
+            onClick={() => {
+              let _values = values;
+              _values.push({
+                key: "",
+                value: "",
+              });
+              setValues(_values);
+            }}
+          >
+            <i className="material-icons">add</i> Add Row
+          </AddRowButton>
+          <Spacer />
+        </InputWrapper>
+      </StyledInputArray>
+    </>
+  );
+};
+
+export default NamespaceAnnotations;
+
+const Spacer = styled.div`
+  width: 10px;
+  height: 20px;
+`;
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled?: boolean; width: string }) =>
+    props.width ? props.width : "270px"};
+  color: ${(props: { disabled?: boolean; width: string }) =>
+    props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;

+ 98 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/PorterYAMLErrorsModal.tsx

@@ -0,0 +1,98 @@
+import React from "react";
+import styled from "styled-components";
+import TitleSection from "components/TitleSection";
+import danger from "assets/danger.svg";
+import info from "assets/info.svg";
+import Modal from "main/home/modals/Modal";
+
+interface PorterYAMLErrorsModalProps {
+  errors: string[];
+  onClose: (...args: any[]) => void;
+  repo: string;
+  branch?: string;
+}
+
+const PorterYAMLErrorsModal = ({
+  errors,
+  onClose,
+  repo,
+  branch,
+}: PorterYAMLErrorsModalProps) => {
+  if (!errors.length) {
+    return null;
+  }
+
+  return (
+    <Modal onRequestClose={() => onClose()} height="auto">
+      <TitleSection icon={danger}>
+        <Text>porter.yaml</Text>
+      </TitleSection>
+      <InfoRow>
+        <InfoTab>
+          <img src={info} /> <Bold>Repo:</Bold>
+          {repo}
+        </InfoTab>
+        {branch ? (
+          <InfoTab>
+            <img src={info} /> <Bold>Branch:</Bold>
+            {branch}
+          </InfoTab>
+        ) : null}
+      </InfoRow>
+      <Message>
+        {errors.map((el) => {
+          return (
+            <div>
+              {"- "}
+              {el}
+            </div>
+          );
+        })}
+      </Message>
+    </Modal>
+  );
+};
+
+const Text = styled.div`
+  font-weight: 500;
+  font-size: 18px;
+  z-index: 999;
+`;
+
+const InfoTab = styled.div`
+  display: flex;
+  align-items: center;
+  opacity: 50%;
+  font-size: 13px;
+  margin-right: 15px;
+  justify-content: center;
+
+  > img {
+    width: 13px;
+    margin-right: 7px;
+  }
+`;
+
+const InfoRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  margin-bottom: 12px;
+`;
+
+const Bold = styled.div`
+  font-weight: 500;
+  margin-right: 5px;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  margin-top: 40px;
+`;
+
+export default PorterYAMLErrorsModal;

+ 14 - 13
dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx

@@ -3,6 +3,7 @@ import styled from "styled-components";
 import DashboardHeader from "../../DashboardHeader";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import api from "shared/api";
+import Banner from "components/Banner";
 
 export const PreviewEnvironmentsHeader = () => {
   const [githubStatus, setGithubStatus] = useState<string>(
@@ -24,28 +25,28 @@ export const PreviewEnvironmentsHeader = () => {
     <>
       <DashboardHeader
         image={PullRequestIcon}
-        title="Preview Environments"
+        title="Preview environments"
         description="Create full-stack preview environments for your pull requests."
         disableLineBreak
+        capitalize={false}
       />
       {githubStatus != "no active incidents" ? (
-        <AlertCard>
-          <AlertCardIcon className="material-icons">error</AlertCardIcon>
-          <AlertCardContent className="content">
-            <AlertCardTitle className="title">
-              Github has an ongoing incident
-            </AlertCardTitle>
-            Active incident:{" "}
-            <a href={`${githubStatus}`} target="_blank">
-              {githubStatus}
-            </a>
-          </AlertCardContent>
-        </AlertCard>
+        <Banner type="error">
+          GitHub has an ongoing incident.
+          <StyledLink href={`${githubStatus}`} target="_blank">
+            View details
+          </StyledLink>
+        </Banner>
       ) : null}
     </>
   );
 };
 
+const StyledLink = styled.a`
+  text-decoration: underline;
+  margin-left: 7px;  
+`;
+
 const AlertCard = styled.div`
   transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
   border-radius: 4px;

+ 213 - 60
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
 import styled, { keyframes } from "styled-components";
 import { DeploymentStatus, PRDeployment } from "../types";
 import pr_icon from "assets/pull_request_icon.svg";
@@ -11,6 +11,78 @@ import Loading from "components/Loading";
 import { ActionButton } from "../components/ActionButton";
 import { EllipsisTextWrapper, RepoLink } from "../components/styled";
 import MaterialTooltip from "@material-ui/core/Tooltip";
+import _ from "lodash";
+
+interface DeploymentCardAction {
+  active: boolean;
+  label: string;
+  action: (...args: any) => void;
+}
+
+interface DeploymentCardActionsDropdownProps {
+  options: DeploymentCardAction[];
+}
+
+const DeploymentCardActionsDropdown = ({
+  options,
+}: DeploymentCardActionsDropdownProps) => {
+  const wrapperRef = useRef<HTMLDivElement>();
+  const [expanded, setExpanded] = useState(false);
+
+  const handleOutsideClick = (event: any) => {
+    if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
+      setExpanded(false);
+    }
+  };
+
+  useEffect(() => {
+    document.addEventListener("mousedown", handleOutsideClick.bind(this));
+
+    return () => {
+      document.removeEventListener("mousedown", handleOutsideClick.bind(this));
+    };
+  }, []);
+
+  return (
+    <div
+      style={{
+        position: "relative",
+      }}
+    >
+      <I
+        className="material-icons"
+        onClick={(e) => {
+          e.preventDefault();
+          e.stopPropagation();
+          setExpanded((expanded) => !expanded);
+        }}
+      >
+        more_vert
+      </I>
+      <ActionsDropdownWrapper expanded={expanded}>
+        <ActionsDropdown ref={wrapperRef}>
+          {options.length ? (
+            <ActionsScrollableWrapper>
+              {options
+                .filter((option) => option.active)
+                .map(({ label, action }, idx) => {
+                  return (
+                    <ActionsRow
+                      isLast={idx === options.length - 1}
+                      onClick={action}
+                      key={label}
+                    >
+                      <ActionsRowText>{label}</ActionsRowText>
+                    </ActionsRow>
+                  );
+                })}
+            </ActionsScrollableWrapper>
+          ) : null}
+        </ActionsDropdown>
+      </ActionsDropdownWrapper>
+    </div>
+  );
+};
 
 const DeploymentCard: React.FC<{
   deployment: PRDeployment;
@@ -100,13 +172,49 @@ const DeploymentCard: React.FC<{
     }
   };
 
+  const DeploymentCardActions = [
+    {
+      active: !!deployment.last_workflow_run_url,
+      label: "View last workflow",
+      action: (e: React.MouseEvent) => {
+        e.preventDefault();
+        e.stopPropagation();
+        window.open(deployment.last_workflow_run_url, "_blank");
+      },
+    },
+    {
+      active: true,
+      label: "Delete",
+      action: (e: React.MouseEvent) => {
+        e.preventDefault();
+        e.stopPropagation();
+        deleteDeployment();
+      },
+    },
+  ];
+
   return (
-    <DeploymentCardWrapper>
+    <DeploymentCardWrapper
+      to={`/preview-environments/details/${deployment.id}?environment_id=${deployment.environment_id}`}
+    >
       <DataContainer>
         <PRName>
           <PRIcon src={pr_icon} alt="pull request icon" />
           <EllipsisTextWrapper tooltipText={deployment.gh_pr_name}>
-            {deployment.gh_pr_name}
+            <StyledLink
+              onClick={(e) => {
+                e.preventDefault();
+                e.stopPropagation();
+                window.open(
+                  `https://github.com/${deployment.gh_repo_owner}/${deployment.gh_repo_name}/pull/${deployment.pull_request_id}`,
+                  "_blank"
+                );
+              }}
+              to={`https://github.com/${deployment.gh_repo_owner}/${deployment.gh_repo_name}/pull/${deployment.pull_request_id}`}
+              target="_blank"
+            >
+              {deployment.gh_pr_name}
+            </StyledLink>
           </EllipsisTextWrapper>
           {deployment.gh_pr_branch_from && deployment.gh_pr_branch_into ? (
             <MergeInfoWrapper>
@@ -126,19 +234,6 @@ const DeploymentCard: React.FC<{
               )}
             </MergeInfoWrapper>
           ) : null}
-          <RepoLink
-            to={`https://github.com/${deployment.gh_repo_owner}/${deployment.gh_repo_name}/pull/${deployment.pull_request_id}`}
-            target="_blank"
-          >
-            <i className="material-icons">open_in_new</i>
-            View PR
-          </RepoLink>
-          {deployment.last_workflow_run_url ? (
-            <RepoLink to={deployment.last_workflow_run_url} target="_blank">
-              <i className="material-icons">open_in_new</i>
-              View last workflow
-            </RepoLink>
-          ) : null}
         </PRName>
 
         <Flex>
@@ -176,55 +271,40 @@ const DeploymentCard: React.FC<{
               </>
             ) : null}
 
-            {deployment.status !== DeploymentStatus.Creating &&
-              deployment.status !== DeploymentStatus.Inactive && (
-                <>
-                  <RowButton
-                    to={`/preview-environments/details/${deployment.namespace}?environment_id=${deployment.environment_id}`}
-                    key={deployment.id}
-                  >
-                    <i className="material-icons-outlined">info</i>
-                    Details
-                  </RowButton>
+            {deployment.status !== DeploymentStatus.Creating && (
+              <>
+                {deployment.subdomain &&
+                deployment.status === DeploymentStatus.Created ? (
                   <RowButton
-                    to={deployment.subdomain}
+                    onClick={(e) => {
+                      e.preventDefault();
+                      e.stopPropagation();
+
+                      window.open(deployment.subdomain, "_blank");
+                    }}
                     key={deployment.subdomain}
-                    target="_blank"
                   >
                     <i className="material-icons">open_in_new</i>
                     View Live
                   </RowButton>
-                </>
-              )}
-            {deployment.status === DeploymentStatus.Inactive ? (
-              <ActionButton
-                onClick={reEnablePreviewEnvironment}
-                disabled={isLoading}
-                hasError={hasErrorOnReEnabling}
-              >
-                {isLoading ? (
-                  <Loading width="198px" height="14px" />
-                ) : (
-                  <>
-                    <i className="material-icons">play_arrow</i>
-                    Activate Preview Environment
-                  </>
-                )}
-              </ActionButton>
-            ) : (
-              <Button
-                onClick={() => {
-                  setCurrentOverlay({
-                    message: `Are you sure you want to delete this deployment?`,
-                    onYes: deleteDeployment,
-                    onNo: () => setCurrentOverlay(null),
-                  });
-                }}
-              >
-                <i className="material-icons">delete</i>
-                Delete
-              </Button>
+                ) : null}
+                <DeploymentCardActionsDropdown
+                  options={DeploymentCardActions}
+                />
+              </>
             )}
+            {/* <Button
+              onClick={() => {
+                setCurrentOverlay({
+                  message: `Are you sure you want to delete this deployment?`,
+                  onYes: deleteDeployment,
+                  onNo: () => setCurrentOverlay(null),
+                });
+              }}
+            >
+              <i className="material-icons">delete</i>
+              Delete
+            </Button> */}
           </>
         ) : (
           <DeleteMessage>
@@ -308,7 +388,7 @@ const PRName = styled.div`
   margin-bottom: 10px;
 `;
 
-const DeploymentCardWrapper = styled.div`
+const DeploymentCardWrapper = styled(DynamicLink)`
   display: flex;
   justify-content: space-between;
   font-size: 13px;
@@ -351,7 +431,7 @@ const PRIcon = styled.img`
   opacity: 50%;
 `;
 
-const RowButton = styled(DynamicLink)`
+const RowButton = styled.button`
   white-space: nowrap;
   font-size: 12px;
   padding: 8px 10px;
@@ -516,3 +596,76 @@ const MergeInfo = styled.div`
     margin: 0 2px;
   }
 `;
+
+const I = styled.i`
+  user-select: none;
+  margin-left: 15px;
+  color: #aaaabb;
+  cursor: pointer;
+  border-radius: 40px;
+  font-size: 18px;
+  width: 30px;
+  height: 30px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  &:hover {
+    background: #26292e;
+    border: 1px solid #494b4f;
+  }
+`;
+
+const ActionsDropdown = styled.div`
+  width: 150px;
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  background: #2f3135;
+  padding: 0;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
+
+const ActionsDropdownWrapper = styled.div<{ expanded: boolean }>`
+  display: ${(props) => (props.expanded ? "block" : "none")};
+  position: absolute;
+  right: calc(-100%);
+  z-index: 1;
+  top: calc(100% + 5px);
+`;
+
+const ActionsScrollableWrapper = styled.div`
+  overflow-y: auto;
+  max-height: 350px;
+`;
+
+const ActionsRow = styled.div<{ isLast: boolean; selected?: boolean }>`
+  width: 100%;
+  height: 35px;
+  padding-left: 10px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  font-size: 13px;
+  background: ${(props) => (props.selected ? "#ffffff11" : "")};
+
+  :hover {
+    background: #ffffff18;
+  }
+`;
+
+const ActionsRowText = styled.div`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  word-break: anywhere;
+  margin-right: 10px;
+  color: white;
+`;
+
+const StyledLink = styled(DynamicLink)`
+  color: white;
+  :hover {
+    text-decoration: underline;
+  }
+`;

+ 284 - 82
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx

@@ -4,7 +4,8 @@ import TitleSection from "components/TitleSection";
 import pr_icon from "assets/pull_request_icon.svg";
 import { useRouteMatch, useLocation } from "react-router";
 import DynamicLink from "components/DynamicLink";
-import { PRDeployment } from "../types";
+import { DeploymentStatus, PRDeployment } from "../types";
+import PullRequestIcon from "assets/pull_request_icon.svg";
 import Loading from "components/Loading";
 import { Context } from "shared/Context";
 import api from "shared/api";
@@ -12,14 +13,22 @@ import ChartList from "../../chart/ChartList";
 import github from "assets/github-white.png";
 import { integrationList } from "shared/common";
 import { capitalize } from "shared/string_utils";
-import leftArrow from "assets/left-arrow.svg";
+import Banner from "components/Banner";
+import Modal from "main/home/modals/Modal";
+import { validatePorterYAML } from "../utils";
+import Placeholder from "components/Placeholder";
+import GithubIcon from "assets/GithubIcon";
 
 const DeploymentDetail = () => {
-  const { params } = useRouteMatch<{ namespace: string }>();
+  const { params } = useRouteMatch<{ id: string }>();
   const context = useContext(Context);
   const [prDeployment, setPRDeployment] = useState<PRDeployment>(null);
   const [environmentId, setEnvironmentId] = useState("");
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
+  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
+  const [expandedPorterYAMLErrors, setExpandedPorterYAMLErrors] = useState<
+    string[]
+  >([]);
 
   const { currentProject, currentCluster } = useContext(Context);
 
@@ -31,10 +40,10 @@ const DeploymentDetail = () => {
     let environment_id = parseInt(searchParams.get("environment_id"));
     setEnvironmentId(searchParams.get("environment_id"));
     api
-      .getPRDeploymentByEnvironment(
+      .getPRDeploymentByID(
         "<token>",
         {
-          namespace: params.namespace,
+          id: parseInt(params.id),
         },
         {
           project_id: currentProject.id,
@@ -46,7 +55,6 @@ const DeploymentDetail = () => {
         if (!isSubscribed) {
           return;
         }
-
         setPRDeployment(data);
       })
       .catch((err) => {
@@ -57,112 +65,294 @@ const DeploymentDetail = () => {
       });
   }, [params]);
 
+  useEffect(() => {
+    if (!prDeployment) {
+      return;
+    }
+
+    const isSubscribed = true;
+    const environment_id = parseInt(searchParams.get("environment_id"));
+
+    validatePorterYAML({
+      projectID: currentProject.id,
+      clusterID: currentCluster.id,
+      environmentID: environment_id,
+      branch: prDeployment.gh_pr_branch_from,
+    })
+      .then(({ data }) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setPorterYAMLErrors(data.errors ?? []);
+      })
+      .catch((err) => {
+        console.error(err);
+        if (isSubscribed) {
+          setPorterYAMLErrors([]);
+        }
+      });
+  }, [prDeployment]);
+
   if (!prDeployment) {
     return <Loading />;
   }
 
-  let repository = `${prDeployment.gh_repo_owner}/${prDeployment.gh_repo_name}`;
+  const repository = `${prDeployment.gh_repo_owner}/${prDeployment.gh_repo_name}`;
+
+  if (
+    !prDeployment.namespace &&
+    ["creating", "updating"].includes(prDeployment.status)
+  ) {
+    return (
+      <>
+        <BreadcrumbRow>
+          <Breadcrumb to={`/preview-environments/deployments/settings`}>
+            <ArrowIcon src={PullRequestIcon} />
+            <Wrap>Preview environments</Wrap>
+          </Breadcrumb>
+          <Slash>/</Slash>
+          <Breadcrumb
+            to={`/preview-environments/deployments/${environmentId}/${repository}`}
+          >
+            <GitIcon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
+            <Wrap>{repository}</Wrap>
+          </Breadcrumb>
+        </BreadcrumbRow>
+        <StyledExpandedChart>
+          <HeaderWrapper>
+            <Title
+              icon={pr_icon}
+              iconWidth="25px"
+              onClick={() =>
+                window.open(
+                  `https://github.com/${repository}/pull/${prDeployment.pull_request_id}`,
+                  "_blank"
+                )
+              }
+            >
+              {prDeployment.gh_pr_name}
+            </Title>
+            <InfoWrapper>
+              {prDeployment.subdomain && (
+                <PRLink to={prDeployment.subdomain} target="_blank">
+                  <i className="material-icons">link</i>
+                  {prDeployment.subdomain}
+                </PRLink>
+              )}
+            </InfoWrapper>
+            <Flex>
+              <Status>
+                <StatusDot status={prDeployment.status} />
+                {capitalize(prDeployment.status)}
+              </Status>
+              <Dot>•</Dot>
+              <DeploymentImageContainer>
+                <DeploymentTypeIcon src={integrationList.repo.icon} />
+                <RepositoryName
+                  onMouseOver={() => {
+                    setShowRepoTooltip(true);
+                  }}
+                  onMouseOut={() => {
+                    setShowRepoTooltip(false);
+                  }}
+                >
+                  {repository}
+                </RepositoryName>
+                {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
+              </DeploymentImageContainer>
+              <Dot>•</Dot>
+              <GHALink
+                to={`https://github.com/${prDeployment.gh_repo_owner}/${prDeployment.gh_repo_name}/pulls/${prDeployment.pull_request_id}`}
+                target="_blank"
+              >
+                <GithubIcon />
+                View PR
+                <i className="material-icons">open_in_new</i>
+              </GHALink>
+            </Flex>
+            <LinkToActionsWrapper></LinkToActionsWrapper>
+          </HeaderWrapper>
+          <ChartListWrapper>
+            <Placeholder height="370px">
+              This preview deployment has not been created yet.{" "}
+              <ViewLastWorkflowLink
+                to={`https://github.com/${prDeployment.gh_repo_owner}/${prDeployment.gh_repo_name}/actions`}
+                target="_blank"
+              >
+                View last workflow
+              </ViewLastWorkflowLink>
+            </Placeholder>
+          </ChartListWrapper>
+        </StyledExpandedChart>
+      </>
+    );
+  }
 
   return (
-    <StyledExpandedChart>
+    <>
+      {expandedPorterYAMLErrors.length > 0 && (
+        <Modal
+          onRequestClose={() => setExpandedPorterYAMLErrors([])}
+          height="auto"
+        >
+          <Message>
+            {expandedPorterYAMLErrors.map((el) => {
+              return (
+                <div>
+                  {"- "}
+                  {el}
+                </div>
+              );
+            })}
+          </Message>
+        </Modal>
+      )}
       <BreadcrumbRow>
+        <Breadcrumb to={`/preview-environments/deployments/settings`}>
+          <ArrowIcon src={PullRequestIcon} />
+          <Wrap>Preview environments</Wrap>
+        </Breadcrumb>
+        <Slash>/</Slash>
         <Breadcrumb
           to={`/preview-environments/deployments/${environmentId}/${repository}`}
         >
-          <ArrowIcon src={leftArrow} />
-          <Wrap>Back</Wrap>
+          <GitIcon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
+          <Wrap>{repository}</Wrap>
         </Breadcrumb>
       </BreadcrumbRow>
-      <HeaderWrapper>
-        <Title icon={pr_icon} iconWidth="25px">
-          {prDeployment.gh_pr_name}
-        </Title>
-        <InfoWrapper>
-          {prDeployment.subdomain && (
-            <PRLink to={prDeployment.subdomain} target="_blank">
-              <i className="material-icons">link</i>
-              {prDeployment.subdomain}
-            </PRLink>
-          )}
-          <TagWrapper>
-            Namespace <NamespaceTag>{params.namespace}</NamespaceTag>
-          </TagWrapper>
-        </InfoWrapper>
-        <Flex>
-          <Status>
-            <StatusDot status={prDeployment.status} />
-            {capitalize(prDeployment.status)}
-          </Status>
-          <Dot>•</Dot>
-          <DeploymentImageContainer>
-            <DeploymentTypeIcon src={integrationList.repo.icon} />
-            <RepositoryName
-              onMouseOver={() => {
-                setShowRepoTooltip(true);
-              }}
-              onMouseOut={() => {
-                setShowRepoTooltip(false);
-              }}
-            >
-              {repository}
-            </RepositoryName>
-            {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
-          </DeploymentImageContainer>
-          <Dot>•</Dot>
-          <GHALink
-            to={`https://github.com/${repository}/pull/${prDeployment.pull_request_id}`}
-            target="_blank"
+      <StyledExpandedChart>
+        <HeaderWrapper>
+          <Title
+            icon={pr_icon}
+            iconWidth="25px"
+            onClick={() =>
+              window.open(
+                `https://github.com/${repository}/pull/${prDeployment.pull_request_id}`,
+                "_blank"
+              )
+            }
           >
-            <img src={github} /> GitHub PR
-            <i className="material-icons">open_in_new</i>
-          </GHALink>
-          {prDeployment.last_workflow_run_url ? (
-            <GHALink to={prDeployment.last_workflow_run_url} target="_blank">
-              <span className="material-icons-outlined">
-                play_circle_outline
-              </span>
-              Last workflow run
-              <i className="material-icons">open_in_new</i>
-            </GHALink>
-          ) : null}
-        </Flex>
-        <LinkToActionsWrapper></LinkToActionsWrapper>
-      </HeaderWrapper>
-      <ChartListWrapper>
-        <ChartList
-          currentCluster={context.currentCluster}
-          currentView="cluster-dashboard"
-          sortType="Newest"
-          namespace={params.namespace}
-          disableBottomPadding
-          closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
-        />
-      </ChartListWrapper>
-    </StyledExpandedChart>
+            {prDeployment.gh_pr_name}
+          </Title>
+          <InfoWrapper>
+            {prDeployment.subdomain && (
+              <PRLink to={prDeployment.subdomain} target="_blank">
+                <i className="material-icons">link</i>
+                {prDeployment.subdomain}
+              </PRLink>
+            )}
+            <TagWrapper>
+              Namespace <NamespaceTag>{prDeployment.namespace}</NamespaceTag>
+            </TagWrapper>
+          </InfoWrapper>
+          <Flex>
+            <Status>
+              <StatusDot status={prDeployment.status} />
+              {capitalize(prDeployment.status)}
+            </Status>
+            <Dot>•</Dot>
+            <DeploymentImageContainer>
+              <DeploymentTypeIcon src={integrationList.repo.icon} />
+              <RepositoryName
+                onMouseOver={() => {
+                  setShowRepoTooltip(true);
+                }}
+                onMouseOut={() => {
+                  setShowRepoTooltip(false);
+                }}
+              >
+                {repository}
+              </RepositoryName>
+              {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
+            </DeploymentImageContainer>
+            <Dot>•</Dot>
+            {prDeployment.last_workflow_run_url ? (
+              <GHALink to={prDeployment.last_workflow_run_url} target="_blank">
+                <img src={github} /> View last workflow run
+                <i className="material-icons">open_in_new</i>
+              </GHALink>
+            ) : null}
+          </Flex>
+          <LinkToActionsWrapper></LinkToActionsWrapper>
+        </HeaderWrapper>
+        {porterYAMLErrors.length > 0 ? (
+          <ErrorBannerWrapper>
+            <Banner type="error">
+              Your porter.yaml file has errors. Please fix them before
+              deploying.
+              <LinkButton
+                onClick={() => {
+                  setExpandedPorterYAMLErrors(porterYAMLErrors);
+                }}
+              >
+                View details
+              </LinkButton>
+            </Banner>
+          </ErrorBannerWrapper>
+        ) : null}
+        <ChartListWrapper>
+          <ChartList
+            currentCluster={context.currentCluster}
+            currentView="cluster-dashboard"
+            sortType="Newest"
+            namespace={prDeployment.namespace}
+            disableBottomPadding
+            closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
+          />
+        </ChartListWrapper>
+      </StyledExpandedChart>
+    </>
   );
 };
 
 export default DeploymentDetail;
 
+const ErrorBannerWrapper = styled.div`
+  margin-block: 20px;
+`;
+
+const Slash = styled.div`
+  margin: 0 4px;
+  color: #aaaabb88;
+`;
+
 const ArrowIcon = styled.img`
   width: 15px;
   margin-right: 8px;
   opacity: 50%;
 `;
 
+const LinkButton = styled.a`
+  text-decoration: underline;
+  margin-left: 7px;
+  cursor: pointer;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  margin-top: 40px;
+`;
+
 const BreadcrumbRow = styled.div`
   width: 100%;
   display: flex;
+  margin-top: -5px;
   justify-content: flex-start;
+  align-items: center;
+  margin-bottom: 15px;
 `;
 
 const Breadcrumb = styled(DynamicLink)`
   color: #aaaabb88;
   font-size: 13px;
-  margin-bottom: 15px;
   display: flex;
   align-items: center;
-  margin-top: -10px;
   z-index: 999;
   padding: 5px;
   padding-right: 7px;
@@ -192,7 +382,6 @@ const GHALink = styled(DynamicLink)`
   align-items: center;
 
   :hover {
-    text-decoration: underline;
     color: white;
   }
 
@@ -201,10 +390,7 @@ const GHALink = styled(DynamicLink)`
     margin-right: 9px;
     margin-left: 5px;
 
-    text-decoration: none;
-
     :hover {
-      text-decoration: underline;
       color: white;
     }
   }
@@ -222,6 +408,18 @@ const GHALink = styled(DynamicLink)`
   }
 `;
 
+const ViewLastWorkflowLink = styled(DynamicLink)`
+  display: flex;
+  align-items: center;
+  text-decoration: underline;
+  margin-left: 7px;
+  color: currentcolor;
+
+  :hover {
+    color: white;
+  }
+`;
+
 const LineBreak = styled.div`
   width: calc(100% - 0px);
   height: 1px;
@@ -309,6 +507,11 @@ const Icon = styled.img`
   width: 100%;
 `;
 
+const GitIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+`;
+
 const StyledExpandedChart = styled.div`
   width: 100%;
   z-index: 0;
@@ -373,7 +576,6 @@ const PRLink = styled(DynamicLink)`
 const ChartListWrapper = styled.div`
   width: 100%;
   margin: auto;
-  margin-top: 20px;
   padding-bottom: 125px;
 `;
 

+ 358 - 266
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -2,48 +2,59 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
-import Selector from "components/Selector";
-
 import Loading from "components/Loading";
-
 import _ from "lodash";
 import DeploymentCard from "./DeploymentCard";
 import { Environment, PRDeployment, PullRequest } from "../types";
 import { useRouting } from "shared/routing";
 import { useHistory, useLocation, useParams } from "react-router";
 import { deployments, pull_requests } from "../mocks";
-import PullRequestCard from "./PullRequestCard";
 import DynamicLink from "components/DynamicLink";
-import { PreviewEnvironmentsHeader } from "../components/PreviewEnvironmentsHeader";
-import SearchBar from "components/SearchBar";
-import CheckboxRow from "components/form-components/CheckboxRow";
-import DocsHelper from "components/DocsHelper";
+import DashboardHeader from "../../DashboardHeader";
+import RadioFilter from "components/RadioFilter";
+import Placeholder from "components/Placeholder";
+import Banner from "components/Banner";
+
+import pullRequestIcon from "assets/pull_request_icon.svg";
+import filterOutline from "assets/filter-outline.svg";
+import sort from "assets/sort.svg";
+import { search } from "shared/search";
+import { getPRDeploymentList, validatePorterYAML } from "../utils";
+import { PorterYAMLErrors } from "../errors";
+import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
 
 const AvailableStatusFilters = [
   "all",
+  "creating",
   "created",
   "failed",
-  "active",
-  "inactive",
-  "not_deployed",
+  "timed_out",
+  "updating",
 ];
 
 type AvailableStatusFiltersType = typeof AvailableStatusFilters[number];
 
 const DeploymentList = () => {
+  const [sortOrder, setSortOrder] = useState("Newest");
   const [isLoading, setIsLoading] = useState(true);
   const [hasError, setHasError] = useState(false);
   const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
   const [searchValue, setSearchValue] = useState("");
   const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
+  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
+  const [expandedPorterYAMLErrors, setExpandedPorterYAMLErrors] = useState<
+    string[]
+  >([]);
 
   const [
     statusSelectorVal,
     setStatusSelectorVal,
-  ] = useState<AvailableStatusFiltersType>("active");
+  ] = useState<AvailableStatusFiltersType>("all");
 
-  const { currentProject, currentCluster } = useContext(Context);
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
   const { getQueryParam, pushQueryParams } = useRouting();
   const location = useLocation();
   const history = useHistory();
@@ -55,20 +66,6 @@ const DeploymentList = () => {
 
   const selectedRepo = `${repo_owner}/${repo_name}`;
 
-  const getPRDeploymentList = () => {
-    return api.getPRDeploymentList(
-      "<token>",
-      {
-        environment_id: Number(environment_id),
-      },
-      {
-        project_id: currentProject.id,
-        cluster_id: currentCluster.id,
-      }
-    );
-    // return mockRequest();
-  };
-
   const getEnvironment = () => {
     return api.getEnvironment(
       "<token>",
@@ -102,68 +99,80 @@ const DeploymentList = () => {
     let isSubscribed = true;
     setIsLoading(true);
 
-    Promise.allSettled([getPRDeploymentList(), getEnvironment()]).then(
-      ([getDeploymentsResponse, getEnvironmentResponse]) => {
-        const deploymentList =
-          getDeploymentsResponse.status === "fulfilled"
-            ? getDeploymentsResponse.value.data
-            : {};
-        const environmentList =
-          getEnvironmentResponse.status === "fulfilled"
-            ? getEnvironmentResponse.value.data
-            : {};
-
-        if (!isSubscribed) {
-          return;
-        }
-
-        setDeploymentList(deploymentList.deployments || []);
-        setPullRequests(deploymentList.pull_requests || []);
-
-        setNewCommentsDisabled(environmentList.new_comments_disabled || false);
+    Promise.allSettled([
+      validatePorterYAML({
+        projectID: currentProject.id,
+        clusterID: currentCluster.id,
+        environmentID: Number(environment_id),
+      }),
+      getPRDeploymentList({
+        projectID: currentProject.id,
+        clusterID: currentCluster.id,
+        environmentID: Number(environment_id),
+      }),
+      getEnvironment(),
+    ])
+      .then(
+        ([
+          validatePorterYAMLResponse,
+          getDeploymentsResponse,
+          getEnvironmentResponse,
+        ]) => {
+          const deploymentList =
+            getDeploymentsResponse.status === "fulfilled"
+              ? getDeploymentsResponse.value.data
+              : {};
+          const environmentList =
+            getEnvironmentResponse.status === "fulfilled"
+              ? getEnvironmentResponse.value.data
+              : {};
+          const porterYAMLErrors =
+            validatePorterYAMLResponse.status === "fulfilled"
+              ? validatePorterYAMLResponse.value.data.errors
+              : [];
+
+          if (!isSubscribed) {
+            return;
+          }
+
+          setPorterYAMLErrors(porterYAMLErrors);
+          setDeploymentList(deploymentList.deployments ?? []);
+          setPullRequests(deploymentList.pull_requests || []);
+
+          setNewCommentsDisabled(
+            environmentList.new_comments_disabled || false
+          );
 
-        setIsLoading(false);
-      }
-    );
+          setIsLoading(false);
+        }
+      )
+      .catch((err) => {
+        setDeploymentList([]);
+        setCurrentError(err);
+      });
 
     return () => {
       isSubscribed = false;
     };
-  }, [currentCluster, currentProject]);
+  }, [currentCluster, currentProject, environment_id]);
 
   const handleRefresh = async () => {
     setIsLoading(true);
     try {
-      const { data } = await getPRDeploymentList();
-      setDeploymentList(data.deployments || []);
+      const { data } = await getPRDeploymentList({
+        projectID: currentProject.id,
+        clusterID: currentCluster.id,
+        environmentID: Number(environment_id),
+      });
+      setDeploymentList(data.deployments ?? []);
       setPullRequests(data.pull_requests || []);
     } catch (error) {
       setHasError(true);
       console.error(error);
     }
-    try {
-      const { data } = await getEnvironment();
-      setNewCommentsDisabled(data.new_comments_disabled || false);
-    } catch (error) {
-      setHasError(true);
-      console.error(error);
-    }
     setIsLoading(false);
   };
 
-  const handlePreviewEnvironmentManualCreation = (pullRequest: PullRequest) => {
-    setPullRequests((prev) => {
-      return prev.filter((pr) => {
-        return (
-          pr.pr_title === pullRequest.pr_title &&
-          `${pr.repo_owner}/${pr.repo_name}` ===
-            `${pullRequest.repo_owner}/${pullRequest.repo_name}`
-        );
-      });
-    });
-    handleRefresh();
-  };
-
   const searchFilter = (value: string | number) => {
     const val = String(value);
 
@@ -171,29 +180,41 @@ const DeploymentList = () => {
   };
 
   const filteredDeployments = useMemo(() => {
-    // Only filter out inactive when status filter is "active"
-    if (statusSelectorVal === "active") {
-      return deploymentList
-        .filter((d) => {
-          return d.status !== "inactive";
-        })
-        .filter((d) => {
-          return Object.values(d).find(searchFilter) !== undefined;
-        });
-    }
+    const filteredByStatus = deploymentList.filter((d) => {
+      if (["deleted", "inactive"].includes(d.status)) {
+        return false;
+      }
 
-    if (statusSelectorVal === "inactive") {
-      return deploymentList
-        .filter((d) => {
-          return d.status === "inactive";
-        })
-        .filter((d) => {
-          return Object.values(d).find(searchFilter) !== undefined;
-        });
-    }
+      if (statusSelectorVal === "all") {
+        return true;
+      }
+
+      if (d.status === statusSelectorVal) {
+        return true;
+      }
 
-    return deploymentList;
-  }, [statusSelectorVal, deploymentList, searchValue]);
+      return false;
+    });
+
+    const filteredBySearch = search<PRDeployment>(
+      filteredByStatus,
+      searchValue,
+      {
+        isCaseSensitive: false,
+        keys: ["gh_pr_name", "gh_repo_name", "gh_repo_owner"],
+      }
+    );
+
+    switch (sortOrder) {
+      case "Newest":
+        return _.sortBy(filteredBySearch, "updated_at").reverse();
+      case "Oldest":
+        return _.sortBy(filteredBySearch, "updated_at");
+      case "Alphabetical":
+      default:
+        return _.sortBy(filteredBySearch, "gh_pr_name");
+    }
+  }, [statusSelectorVal, deploymentList, searchValue, sortOrder]);
 
   const filteredPullRequests = useMemo(() => {
     if (statusSelectorVal !== "inactive") {
@@ -208,41 +229,32 @@ const DeploymentList = () => {
   const renderDeploymentList = () => {
     if (isLoading) {
       return (
-        <Placeholder>
+        <LoadingWrapper>
           <Loading />
-        </Placeholder>
+        </LoadingWrapper>
       );
     }
 
-    if (!deploymentList.length && !pullRequests.length) {
+    if (!deploymentList.length) {
       return (
-        <Placeholder>
-          No preview apps have been found. Open a PR to create a new preview
-          app.
+        <Placeholder height="calc(100vh - 400px)">
+          No preview developments have been found. Open a PR to create a new
+          preview app.
         </Placeholder>
       );
     }
 
-    if (!filteredDeployments.length && !filteredPullRequests.length) {
+    if (!filteredDeployments.length) {
       return (
-        <Placeholder>
-          No preview apps have been found with the given filter.
+        <Placeholder height="calc(100vh - 400px)">
+          No preview developments have been found with the given filter.
         </Placeholder>
       );
     }
 
     return (
       <>
-        {filteredPullRequests.map((pr) => {
-          return (
-            <PullRequestCard
-              key={pr.pr_title}
-              pullRequest={pr}
-              onCreation={handlePreviewEnvironmentManualCreation}
-            />
-          );
-        })}
-        {filteredDeployments.map((d) => {
+        {filteredDeployments.map((d: any) => {
           return (
             <DeploymentCard
               key={d.id}
@@ -257,49 +269,63 @@ const DeploymentList = () => {
     );
   };
 
-  const handleStatusFilterChange = (value: string) => {
-    pushQueryParams({ status_filter: value });
-    setStatusSelectorVal(value);
-  };
-
-  const handleToggleCommentStatus = (currentlyDisabled: boolean) => {
-    api
-      .toggleNewCommentForEnvironment(
-        "<token>",
-        {
-          disable: !currentlyDisabled,
-        },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          environment_id: Number(environment_id),
-        }
-      )
-      .then(() => {
-        setNewCommentsDisabled(!currentlyDisabled);
-      });
-  };
+  useEffect(() => {
+    pushQueryParams({ status_filter: statusSelectorVal });
+  }, [statusSelectorVal]);
 
   return (
     <>
-      <PreviewEnvironmentsHeader />
-      <Flex>
-        <BackButton to={"/preview-environments"} className="material-icons">
-          keyboard_backspace
-        </BackButton>
-
-        <Icon
-          src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png"
-          alt="git repository icon"
-        />
-        <Title>{selectedRepo}</Title>
-
-        <ActionsWrapper>
-          <StyledStatusSelector>
-            <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
-              <i className="material-icons">refresh</i>
-            </RefreshButton>
-            <SearchRow>
+      <PorterYAMLErrorsModal
+        errors={expandedPorterYAMLErrors}
+        onClose={() => setExpandedPorterYAMLErrors([])}
+        repo={selectedRepo}
+      />
+
+      <BreadcrumbRow>
+        <Breadcrumb to="/preview-environments">
+          <ArrowIcon src={pullRequestIcon} />
+          <Wrap>Preview environments</Wrap>
+        </Breadcrumb>
+      </BreadcrumbRow>
+      <DashboardHeader
+        image="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png"
+        title={
+          <Flex>
+            <StyledLink
+              to={`https://github.com/${selectedRepo}`}
+              target="_blank"
+            >
+              {selectedRepo}
+            </StyledLink>
+            <DynamicLink
+              to={`/preview-environments/deployments/${environment_id}/${repo_owner}/${repo_name}/settings`}
+            >
+              <I className="material-icons">more_vert</I>
+            </DynamicLink>
+          </Flex>
+        }
+        description={`Preview environments for the ${selectedRepo} repository.`}
+        disableLineBreak
+        capitalize={false}
+      />
+      {porterYAMLErrors.length > 0 ? (
+        <PorterYAMLBannerWrapper>
+          <Banner type="warning">
+            We found some errors in the porter.yaml file in the default branch.
+            <LinkButton
+              onClick={() => {
+                setExpandedPorterYAMLErrors(porterYAMLErrors);
+              }}
+            >
+              Learn more
+            </LinkButton>
+          </Banner>
+        </PorterYAMLBannerWrapper>
+      ) : null}
+      <FlexRow>
+        <Flex>
+          <SearchRowWrapper>
+            <SearchBarWrapper>
               <i className="material-icons">search</i>
               <SearchInput
                 value={searchValue}
@@ -308,46 +334,44 @@ const DeploymentList = () => {
                 }}
                 placeholder="Search"
               />
-            </SearchRow>
-            <Selector
-              activeValue={statusSelectorVal}
-              setActiveValue={handleStatusFilterChange}
-              options={[
-                {
-                  value: "active",
-                  label: "Active",
-                },
-                {
-                  value: "inactive",
-                  label: "Inactive",
-                },
-              ]}
-              dropdownLabel="Status"
-              width="150px"
-              dropdownWidth="230px"
-              closeOverlay={true}
-            />
-          </StyledStatusSelector>
-        </ActionsWrapper>
-      </Flex>
-      <Flex>
-        <ActionsWrapper>
-          <FlexWrap>
-            <CheckboxRow
-              label="Disable new comments for deployments"
-              checked={newCommentsDisabled}
-              toggle={() => handleToggleCommentStatus(newCommentsDisabled)}
-            />
-            <Div>
-              <DocsHelper
-                disableMargin
-                tooltipText="When checked, comments for every new deployment are disabled. Instead, the most recent comment is updated each time."
-                placement="top-end"
-              />
-            </Div>
-          </FlexWrap>
-        </ActionsWrapper>
-      </Flex>
+            </SearchBarWrapper>
+          </SearchRowWrapper>
+          <RadioFilter
+            icon={filterOutline}
+            selected={statusSelectorVal}
+            setSelected={setStatusSelectorVal}
+            options={AvailableStatusFilters.map((filter) => ({
+              value: filter,
+              label: _.startCase(filter),
+            }))}
+            name="Status"
+          />
+        </Flex>
+        <Flex>
+          <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
+            <i className="material-icons">refresh</i>
+          </RefreshButton>
+          <RadioFilter
+            icon={sort}
+            selected={sortOrder}
+            setSelected={setSortOrder}
+            options={[
+              { label: "Newest", value: "Newest" },
+              { label: "Oldest", value: "Oldest" },
+              { label: "Alphabetical", value: "Alphabetical" },
+            ]}
+            name="Sort"
+          />
+          <CreatePreviewEnvironmentButton
+            disabled={porterYAMLErrors.some(
+              (err) => err === PorterYAMLErrors.FileNotFound
+            )}
+            to={`/preview-environments/deployments/${environment_id}/${repo_owner}/${repo_name}/create`}
+          >
+            <i className="material-icons">add</i> New preview deployment
+          </CreatePreviewEnvironmentButton>
+        </Flex>
+      </FlexRow>
       <Container>
         <EventsGrid>{renderDeploymentList()}</EventsGrid>
       </Container>
@@ -368,50 +392,89 @@ const mockRequest = () =>
     );
   });
 
-const Flex = styled.div`
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
+`;
+
+const I = styled.i`
+  font-size: 18px;
+  user-select: none;
+  margin-left: 15px;
+  color: #aaaabb;
+  margin-bottom: -3px;
+  cursor: pointer;
+  width: 30px;
+  border-radius: 40px;
+  height: 30px;
   display: flex;
   align-items: center;
+  justify-content: center;
+  :hover {
+    background: #26292e;
+    border: 1px solid #494b4f;
+  }
 `;
 
-const Div = styled.div`
-  margin-bottom: -7px;
+const StyledLink = styled(DynamicLink)`
+  color: white;
+  :hover {
+    text-decoration: underline;
+  }
 `;
 
-const FlexWrap = styled.div`
+const LinkButton = styled.a`
+  text-decoration: underline;
+  margin-left: 7px;
+  cursor: pointer;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  margin-top: 40px;
+`;
+
+const BreadcrumbRow = styled.div`
+  width: 100%;
+  margin-top: 5px;
   display: flex;
-  align-items: center;
+  justify-content: flex-start;
 `;
 
-const BackButton = styled(DynamicLink)`
-  cursor: pointer;
-  font-size: 24px;
-  color: #969fbbaa;
-  padding: 3px;
-  border-radius: 100px;
-  :hover {
-    background: #ffffff11;
-  }
+const ArrowIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+  opacity: 50%;
 `;
 
-const Icon = styled.img`
-  width: 25px;
-  height: 25px;
-  margin-right: 6px;
-  margin-left: 14px;
+const Wrap = styled.div`
+  z-index: 999;
 `;
 
-const Title = styled.div`
-  font-size: 20px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 10px;
-  border-radius: 2px;
-  color: #ffffff;
+const Breadcrumb = styled(DynamicLink)`
+  color: #aaaabb88;
+  font-size: 13px;
+  margin-bottom: 15px;
+  display: flex;
+  align-items: center;
+  margin-top: -10px;
+  z-index: 999;
+  padding: 5px;
+  padding-right: 7px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+  }
 `;
 
-const ActionsWrapper = styled.div`
+const Flex = styled.div`
   display: flex;
-  margin-left: auto;
+  align-items: center;
 `;
 
 const RefreshButton = styled.button`
@@ -436,26 +499,6 @@ const RefreshButton = styled.button`
   }
 `;
 
-const Placeholder = styled.div`
-  padding: 30px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 400px;
-  height: 40vh;
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
 const Container = styled.div`
   margin-top: 33px;
   padding-bottom: 120px;
@@ -467,15 +510,6 @@ const EventsGrid = styled.div`
   grid-template-columns: 1;
 `;
 
-const StyledStatusSelector = styled.div`
-  display: flex;
-  align-items: center;
-  font-size: 13px;
-  :not(:first-child) {
-    margin-left: 15px;
-  }
-`;
-
 const SearchInput = styled.input`
   outline: none;
   border: none;
@@ -483,30 +517,88 @@ const SearchInput = styled.input`
   background: none;
   width: 100%;
   color: white;
-  padding: 0;
-  height: 20px;
+  height: 100%;
 `;
 
 const SearchRow = styled.div`
   display: flex;
-  width: 100%;
-  font-size: 13px;
-  color: #ffffff55;
-  border-radius: 4px;
-  user-select: none;
   align-items: center;
-  padding: 10px 0px;
-  min-width: 300px;
-  max-width: min-content;
-  max-height: 35px;
-  background: #ffffff11;
-  margin-right: 15px;
+  height: 30px;
+  margin-right: 10px;
+  background: #26292e;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
 
-  i {
+const SearchRowWrapper = styled(SearchRow)`
+  border-radius: 5px;
+  width: 250px;
+`;
+
+const SearchBarWrapper = styled.div`
+  display: flex;
+  flex: 1;
+
+  > i {
+    color: #aaaabb;
+    padding-top: 1px;
+    margin-left: 8px;
+    font-size: 16px;
+    margin-right: 8px;
+  }
+`;
+
+const FlexRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 10px;
+`;
+
+const CreatePreviewEnvironmentButton = styled(DynamicLink)`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-left: 10px;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  min-width: 155px;
+  padding-right: 13px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
     width: 18px;
     height: 18px;
-    margin-left: 12px;
-    margin-right: 12px;
-    font-size: 20px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
   }
 `;
+
+const PorterYAMLBannerWrapper = styled.div`
+  margin-block: 15px;
+`;

+ 0 - 33
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/PullRequestCard.tsx

@@ -2,12 +2,10 @@ import React, { useState, useContext } from "react";
 import styled from "styled-components";
 import pr_icon from "assets/pull_request_icon.svg";
 import { PullRequest } from "../types";
-import { integrationList } from "shared/common";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { ActionButton } from "../components/ActionButton";
 import Loading from "components/Loading";
-import DynamicLink from "components/DynamicLink";
 import RecreateWorkflowFilesModal from "../components/RecreateWorkflowFilesModal";
 import { EllipsisTextWrapper, RepoLink } from "../components/styled";
 
@@ -193,37 +191,6 @@ const StatusDot = styled.div`
   margin-left: 3px;
 `;
 
-const DeploymentImageContainer = styled.div`
-  height: 20px;
-  font-size: 13px;
-  position: relative;
-  display: flex;
-  margin-left: 15px;
-  align-items: center;
-  font-weight: 400;
-  justify-content: center;
-  color: #ffffff66;
-  padding-left: 5px;
-`;
-
-const Icon = styled.img`
-  width: 100%;
-`;
-
-const DeploymentTypeIcon = styled(Icon)`
-  width: 20px;
-  margin-right: 10px;
-`;
-
-const RepositoryName = styled.div`
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  max-width: 390px;
-  position: relative;
-  margin-right: 3px;
-`;
-
 const Tooltip = styled.div`
   position: absolute;
   left: 14px;

+ 461 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateEnvironment.tsx

@@ -0,0 +1,461 @@
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { useParams } from "react-router";
+import { PullRequest } from "../types";
+import DashboardHeader from "../../DashboardHeader";
+import PullRequestIcon from "assets/pull_request_icon.svg";
+import Helper from "components/form-components/Helper";
+import pr_icon from "assets/pull_request_icon.svg";
+import api from "shared/api";
+import { EllipsisTextWrapper, RepoLink } from "../components/styled";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { getPRDeploymentList, validatePorterYAML } from "../utils";
+import Banner from "components/Banner";
+import Modal from "main/home/modals/Modal";
+import { useRouting } from "shared/routing";
+import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
+import { PlaceHolder } from "brace";
+import Placeholder from "components/Placeholder";
+
+const CreateEnvironment: React.FC = () => {
+  const router = useRouting();
+  const queryClient = useQueryClient();
+  const [showErrorsModal, setShowErrorsModal] = useState<boolean>(false);
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const { environment_id, repo_name, repo_owner } = useParams<{
+    environment_id: string;
+    repo_name: string;
+    repo_owner: string;
+  }>();
+
+  const { isLoading: getPullRequestsLoading, data: pullRequests } = useQuery<
+    PullRequest[]
+  >(
+    ["pullRequests", currentProject.id, currentCluster.id, environment_id],
+    async () => {
+      try {
+        const res = await getPRDeploymentList({
+          projectID: currentProject.id,
+          clusterID: currentCluster.id,
+          environmentID: Number(environment_id),
+        });
+
+        return res.data.pull_requests || [];
+      } catch (err) {
+        setCurrentError(err);
+      }
+    }
+  );
+
+  const [selectedPR, setSelectedPR] = useState<PullRequest>();
+  const [loading, setLoading] = useState(false);
+  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
+
+  const selectedRepo = `${repo_owner}/${repo_name}`;
+
+  const handlePRRowItemClick = async (pullRequest: PullRequest) => {
+    setSelectedPR(pullRequest);
+    setLoading(true);
+
+    const res = await validatePorterYAML({
+      projectID: currentProject.id,
+      clusterID: currentCluster.id,
+      environmentID: Number(environment_id),
+      branch: pullRequest.branch_from,
+    });
+
+    setPorterYAMLErrors(res.data.errors ?? []);
+
+    setLoading(false);
+  };
+
+  const handleCreatePreviewDeployment = async () => {
+    try {
+      await api.createPreviewEnvironmentDeployment("<token>", selectedPR, {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+      });
+
+      router.push(
+        `/preview-environments/deployments/${environment_id}/${selectedPR.repo_owner}/${selectedPR.repo_name}?status_filter=all`
+      );
+    } catch (err) {
+      setCurrentError(err);
+    }
+  };
+
+  const renderPullRequestList = () => {
+    return (
+      <>
+        <Helper>
+          Select an open pull request to preview. Pull requests must contain a{" "}
+          <Code>porter.yaml</Code> file.
+        </Helper>
+        <Br height="10px" />
+        <PullRequestList>
+          {(pullRequests ?? []).map((pullRequest: PullRequest, i: number) => {
+            return (
+              <PullRequestRow
+                onClick={() => {
+                  handlePRRowItemClick(pullRequest);
+                }}
+                isLast={i === pullRequests.length - 1}
+                isSelected={pullRequest === selectedPR}
+              >
+                <PRName>
+                  <PRIcon src={pr_icon} alt="pull request icon" />
+                  <EllipsisTextWrapper tooltipText={pullRequest.pr_title}>
+                    {pullRequest.pr_title}
+                  </EllipsisTextWrapper>
+                </PRName>
+
+                <Flex>
+                  <DeploymentImageContainer>
+                    {/* <InfoWrapper>
+                    <LastDeployed>
+                      #{pullRequest.pr_number} last updated xyz
+                    </LastDeployed>
+                  </InfoWrapper>
+                  <SepDot>•</SepDot> */}
+                    <MergeInfoWrapper>
+                      <MergeInfo>
+                        {pullRequest.branch_from}
+                        <i className="material-icons">arrow_forward</i>
+                        {pullRequest.branch_into}
+                      </MergeInfo>
+                    </MergeInfoWrapper>
+                  </DeploymentImageContainer>
+                </Flex>
+              </PullRequestRow>
+            );
+          })}
+        </PullRequestList>
+        {showErrorsModal && selectedPR ? (
+          <PorterYAMLErrorsModal
+            errors={porterYAMLErrors}
+            onClose={() => setShowErrorsModal(false)}
+            repo={selectedPR.repo_owner + "/" + selectedPR.repo_name}
+            branch={selectedPR.branch_from}
+          />
+        ) : null}
+        {selectedPR && porterYAMLErrors.length ? (
+          <ValidationErrorBannerWrapper>
+            <Banner type="warning">
+              We found some errors in the porter.yaml file in the&nbsp;
+              {selectedPR.branch_from}&nbsp;branch. &nbsp;
+              <LearnMoreButton onClick={() => setShowErrorsModal(true)}>
+                Learn more
+              </LearnMoreButton>
+            </Banner>
+          </ValidationErrorBannerWrapper>
+        ) : null}
+        <CreatePreviewDeploymentWrapper>
+          <SubmitButton
+            onClick={handleCreatePreviewDeployment}
+            disabled={loading || !selectedPR || porterYAMLErrors.length > 0}
+          >
+            Create preview deployment
+          </SubmitButton>
+          {selectedPR && porterYAMLErrors.length ? (
+            <RevalidatePorterYAMLSpanWrapper>
+              Please fix your porter.yaml file to continue.{" "}
+              <RevalidateSpan
+                onClick={(e) => {
+                  e.preventDefault();
+                  e.stopPropagation();
+
+                  if (!selectedPR) {
+                    return;
+                  }
+
+                  handlePRRowItemClick(selectedPR);
+                }}
+              >
+                Refresh
+              </RevalidateSpan>
+            </RevalidatePorterYAMLSpanWrapper>
+          ) : null}
+        </CreatePreviewDeploymentWrapper>
+      </>
+    );
+  };
+
+  return (
+    <>
+      <BreadcrumbRow>
+        <Breadcrumb to={`/preview-environments/deployments/settings`}>
+          <ArrowIcon src={PullRequestIcon} />
+          <Wrap>Preview environments</Wrap>
+        </Breadcrumb>
+        <Slash>/</Slash>
+        <Breadcrumb
+          to={`/preview-environments/deployments/${environment_id}/${selectedRepo}`}
+        >
+          <Icon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
+          <Wrap>{selectedRepo}</Wrap>
+        </Breadcrumb>
+      </BreadcrumbRow>
+      <DashboardHeader
+        title="Create a preview deployment"
+        disableLineBreak
+        capitalize={false}
+      />
+      <DarkMatter />
+      {pullRequests?.length ? (
+        renderPullRequestList()
+      ) : (
+        <>
+          <Br height="30px" />
+          <Placeholder height="370px">
+            You do not have any pull requests.
+          </Placeholder>
+        </>
+      )}
+    </>
+  );
+};
+
+export default CreateEnvironment;
+
+const PullRequestList = styled.div`
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  overflow: hidden;
+`;
+
+const PullRequestRow = styled.div<{ isLast?: boolean; isSelected?: boolean }>`
+  width: 100%;
+  padding: 15px;
+  cursor: pointer;
+  background: ${(props) => (props.isSelected ? "#ffffff11" : "#26292e")};
+  border-bottom: ${(props) => (props.isLast ? "" : "1px solid #494b4f")};
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const Code = styled.span`
+  font-family: monospace; ;
+`;
+
+const SepDot = styled.div`
+  color: #aaaabb66;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 10px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 8px;
+  margin-left: 7px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-top: -1px;
+  margin-left: 10px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const MergeInfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 8px;
+  position: relative;
+  margin-left: 10px;
+`;
+
+const MergeInfo = styled.div`
+  font-size: 13px;
+  align-items: center;
+  color: #aaaabb66;
+  white-space: nowrap;
+  display: flex;
+  align-items: center;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 300px;
+
+  > i {
+    font-size: 16px;
+    margin: 0 2px;
+  }
+`;
+
+const PRIcon = styled.img`
+  font-size: 20px;
+  height: 16px;
+  margin-right: 10px;
+  color: #aaaabb;
+  opacity: 50%;
+`;
+
+const PRName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  display: flex;
+  font-size: 14px;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const SubmitButton = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  width: 200px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -15px;
+`;
+
+const Br = styled.div<{ height: string }>`
+  width: 100%;
+  height: ${(props) => props.height || "2px"};
+`;
+
+const Slash = styled.div`
+  margin: 0 4px;
+  color: #aaaabb88;
+`;
+
+const Wrap = styled.div`
+  z-index: 999;
+`;
+
+const ArrowIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+  opacity: 50%;
+`;
+
+const Icon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+`;
+
+const BreadcrumbRow = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: flex-start;
+  margin-bottom: 15px;
+  margin-top: -10px;
+  align-items: center;
+`;
+
+const Breadcrumb = styled(DynamicLink)`
+  color: #aaaabb88;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  z-index: 999;
+  padding: 5px;
+  padding-right: 7px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const ValidationErrorBannerWrapper = styled.div`
+  margin-block: 20px;
+`;
+
+const LearnMoreButton = styled.div`
+  text-decoration: underline;
+  fontweight: bold;
+  cursor: pointer;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  margin-top: 40px;
+`;
+
+const CreatePreviewDeploymentWrapper = styled.div`
+  margin-top: 30px;
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+`;
+
+const RevalidatePorterYAMLSpanWrapper = styled.div`
+  font-size: 13px;
+  color: #aaaabb;
+`;
+
+const RevalidateSpan = styled.span`
+  color: #aaaabb;
+  text-decoration: underline;
+  cursor: pointer;
+`;

+ 11 - 12
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx

@@ -2,11 +2,8 @@ import React, { useContext, useState } from "react";
 import { capitalize } from "shared/string_utils";
 import styled from "styled-components";
 import { Environment } from "../types";
-import Options from "components/OptionsDropdown";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import Modal from "main/home/modals/Modal";
-import InputRow from "components/form-components/InputRow";
 import DynamicLink from "components/DynamicLink";
 import { RepoLink } from "../components/styled";
 
@@ -74,7 +71,7 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
 
   return (
     <>
-      {showDeleteModal ? (
+      {/* {showDeleteModal ? (
         <Modal
           title={`Remove Preview Envs for ${git_repo_owner}/${git_repo_name}`}
           width="800px"
@@ -102,7 +99,7 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
             </DeleteButton>
           </ActionWrapper>
         </Modal>
-      ) : null}
+      ) : null} */}
       <EnvironmentCardWrapper
         to={`/preview-environments/deployments/${id}/${git_repo_owner}/${git_repo_name}`}
       >
@@ -116,6 +113,15 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
             <RepoLink
               to={`https://github.com/${git_repo_owner}/${git_repo_name}`}
               target="_blank"
+              onClick={(e) => {
+                e.preventDefault();
+                e.stopPropagation();
+
+                window.open(
+                  `https://github.com/${git_repo_owner}/${git_repo_name}`,
+                  "_blank"
+                );
+              }}
             >
               <i className="material-icons">open_in_new</i>
               View Repo
@@ -141,13 +147,6 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
             )}
           </Status>
         </DataContainer>
-        <OptionWrapper>
-          <Options.Dropdown expandIcon="more_vert" shrinkIcon="more_vert">
-            <Options.Option onClick={() => setShowDeleteModal(true)}>
-              <i className="material-icons">delete</i> Delete
-            </Options.Option>
-          </Options.Dropdown>
-        </OptionWrapper>
       </EnvironmentCardWrapper>
     </>
   );

+ 484 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentSettings.tsx

@@ -0,0 +1,484 @@
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import styled from "styled-components";
+import { useParams } from "react-router";
+import DashboardHeader from "../../DashboardHeader";
+import PullRequestIcon from "assets/pull_request_icon.svg";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import { Environment, EnvironmentDeploymentMode } from "../types";
+import SaveButton from "components/SaveButton";
+import _ from "lodash";
+import { Context } from "shared/Context";
+import PageNotFound from "components/PageNotFound";
+import Banner from "components/Banner";
+import InputRow from "components/form-components/InputRow";
+import Modal from "main/home/modals/Modal";
+import { useRouting } from "shared/routing";
+import NamespaceAnnotations, {
+  KeyValueType,
+} from "../components/NamespaceAnnotations";
+import BranchFilterSelector from "../components/BranchFilterSelector";
+
+const EnvironmentSettings = () => {
+  const router = useRouting();
+  const [isLoadingBranches, setIsLoadingBranches] = useState<boolean>(false);
+  const [availableBranches, setAvailableBranches] = useState<string[]>([]);
+  const [showDeleteModal, setShowDeleteModal] = useState(false);
+  const [deleteConfirmationPrompt, setDeleteConfirmationPrompt] = useState("");
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [selectedBranches, setSelectedBranches] = useState([]);
+  const [environment, setEnvironment] = useState<Environment>();
+  const [saveStatus, setSaveStatus] = useState("");
+  const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
+  const [
+    deploymentMode,
+    setDeploymentMode,
+  ] = useState<EnvironmentDeploymentMode>("manual");
+  const [namespaceAnnotations, setNamespaceAnnotations] = useState<
+    KeyValueType[]
+  >([]);
+  const {
+    environment_id: environmentId,
+    repo_name: repoName,
+    repo_owner: repoOwner,
+  } = useParams<{
+    environment_id: string;
+    repo_name: string;
+    repo_owner: string;
+  }>();
+
+  const selectedRepo = `${repoOwner}/${repoName}`;
+
+  useEffect(() => {
+    const getPreviewEnvironmentSettings = async () => {
+      const { data: environment } = await api.getEnvironment<Environment>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          environment_id: parseInt(environmentId),
+        }
+      );
+
+      setEnvironment(environment);
+      setSelectedBranches(environment.git_repo_branches);
+      setNewCommentsDisabled(environment.new_comments_disabled);
+      setDeploymentMode(environment.mode);
+
+      if (environment.namespace_annotations) {
+        const annotations: KeyValueType[] = [];
+
+        Object.keys(environment.namespace_annotations).forEach((k) => {
+          annotations.push({
+            key: k,
+            value: environment.namespace_annotations[k],
+          });
+        });
+
+        setNamespaceAnnotations(annotations);
+      }
+    };
+
+    try {
+      getPreviewEnvironmentSettings();
+    } catch (err) {
+      setCurrentError(err);
+    }
+  }, []);
+
+  useEffect(() => {
+    if (!environment) {
+      return;
+    }
+
+    const repoName = environment.git_repo_name;
+    const repoOwner = environment.git_repo_owner;
+    setIsLoadingBranches(true);
+    api
+      .getBranches<string[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          kind: "github",
+          name: repoName,
+          owner: repoOwner,
+          git_repo_id: environment.git_installation_id,
+        }
+      )
+      .then(({ data }) => {
+        setIsLoadingBranches(false);
+        setAvailableBranches(data);
+      })
+      .catch(() => {
+        setIsLoadingBranches(false);
+        setCurrentError(
+          "Couldn't load branches for this repository, using all branches by default."
+        );
+      });
+  }, [environment]);
+
+  const handleSave = async () => {
+    let annotations: Record<string, string> = {};
+
+    setSaveStatus("loading");
+
+    namespaceAnnotations
+      .filter((elem: KeyValueType, index: number, self: KeyValueType[]) => {
+        // remove any collisions that are duplicates
+        let numCollisions = self.reduce((n, _elem: KeyValueType) => {
+          return n + (_elem.key === elem.key ? 1 : 0);
+        }, 0);
+
+        if (numCollisions == 1) {
+          return true;
+        } else {
+          return (
+            index ===
+            self.findIndex((_elem: KeyValueType) => _elem.key === elem.key)
+          );
+        }
+      })
+      .forEach((elem: KeyValueType) => {
+        if (elem.key !== "" && elem.value !== "") {
+          annotations[elem.key] = elem.value;
+        }
+      });
+
+    try {
+      await api.updateEnvironment(
+        "<token>",
+        {
+          mode: deploymentMode,
+          disable_new_comments: newCommentsDisabled,
+          git_repo_branches: selectedBranches,
+          namespace_annotations: annotations,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          environment_id: Number(environmentId),
+        }
+      );
+    } catch (err) {
+      setCurrentError(err);
+    }
+
+    setSaveStatus("");
+  };
+
+  const closeDeleteConfirmationModal = () => {
+    setShowDeleteModal(false);
+    setDeleteConfirmationPrompt("");
+  };
+
+  const canDelete = useMemo(() => {
+    return deleteConfirmationPrompt === `${repoOwner}/${repoName}`;
+  }, [deleteConfirmationPrompt]);
+
+  const handleDelete = async () => {
+    if (!canDelete) {
+      return;
+    }
+
+    try {
+      await api.deleteEnvironment(
+        "<token>",
+        {
+          name: environment?.name,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          git_installation_id: environment?.git_installation_id,
+          git_repo_owner: repoOwner,
+          git_repo_name: repoName,
+        }
+      );
+
+      closeDeleteConfirmationModal();
+      router.push(`/preview-environments`);
+    } catch (err) {
+      setCurrentError(JSON.stringify(err));
+      closeDeleteConfirmationModal();
+    }
+  };
+
+  return (
+    <>
+      {showDeleteModal ? (
+        <DeletePreviewEnvironmentModal
+          repoOwner={repoOwner}
+          repoName={repoName}
+          onClose={closeDeleteConfirmationModal}
+          prompt={deleteConfirmationPrompt}
+          setPrompt={setDeleteConfirmationPrompt}
+          onDelete={handleDelete}
+          disabled={!canDelete}
+        />
+      ) : null}
+      <BreadcrumbRow>
+        <Breadcrumb to={`/preview-environments/deployments/settings`}>
+          <ArrowIcon src={PullRequestIcon} />
+          <Wrap>Preview environments</Wrap>
+        </Breadcrumb>
+        <Slash>/</Slash>
+        <Breadcrumb
+          to={`/preview-environments/deployments/${environmentId}/${selectedRepo}`}
+        >
+          <Icon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
+          <Wrap>{selectedRepo}</Wrap>
+        </Breadcrumb>
+      </BreadcrumbRow>
+      <DashboardHeader
+        image={PullRequestIcon}
+        title="Preview environment settings"
+        description={`Preview environment settings for the ${selectedRepo} repository.`}
+        disableLineBreak
+        capitalize={false}
+      />
+      <WarningBannerWrapper>
+        <Banner type="warning">
+          Changes made here will not affect existing deployments in this preview
+          environment.
+        </Banner>
+      </WarningBannerWrapper>
+      <StyledPlaceholder>
+        <Heading isAtTop>Pull request comment settings</Heading>
+        <Helper>
+          Update the most recent PR comment on every deploy. If disabled, a new
+          PR comment is made per deploy.
+        </Helper>
+        <CheckboxRow
+          label="Update the most recent PR comment"
+          checked={newCommentsDisabled}
+          toggle={() => setNewCommentsDisabled(!newCommentsDisabled)}
+        />
+        <Br />
+        <Heading>Automatic preview deployments</Heading>
+        <Helper>
+          When enabled, preview deployments are automatically created for all
+          new pull requests.
+        </Helper>
+        <CheckboxRow
+          label="Automatically create preview deployments"
+          checked={deploymentMode === "auto"}
+          toggle={() =>
+            setDeploymentMode((deploymentMode) =>
+              deploymentMode === "auto" ? "manual" : "auto"
+            )
+          }
+        />
+        <Br />
+        <Heading>Select allowed branches</Heading>
+        <Helper>
+          If the pull request has a base branch included in this list, it will
+          be allowed to be deployed.
+          <br />
+          (Leave empty to allow all branches)
+        </Helper>
+        <BranchFilterSelector
+          onChange={setSelectedBranches}
+          options={availableBranches}
+          value={selectedBranches}
+          showLoading={isLoadingBranches}
+        />
+        <Br />
+        <Heading>Namespace annotations</Heading>
+        <Helper>
+          Custom annotations to be injected into the Kubernetes namespace
+          created for each deployment.
+        </Helper>
+        <NamespaceAnnotations
+          values={namespaceAnnotations}
+          setValues={(x: KeyValueType[]) => {
+            let annotations: KeyValueType[] = [];
+            x.forEach((entry) => {
+              annotations.push({ key: entry.key, value: entry.value });
+            });
+            setNamespaceAnnotations(annotations);
+          }}
+        />
+        <SavePreviewEnvironmentSettings
+          text={"Save"}
+          status={saveStatus}
+          clearPosition={true}
+          statusPosition={"right"}
+          onClick={handleSave}
+        />
+        <Br />
+        <Heading>Delete preview environment</Heading>
+        <Helper>
+          Delete the Porter preview environment integration for this repo. All
+          preview deployments will also be destroyed.
+        </Helper>
+        <DeleteButton
+          disabled={saveStatus === "loading"}
+          onClick={() => {
+            setShowDeleteModal(true);
+          }}
+        >
+          Delete preview environment
+        </DeleteButton>
+      </StyledPlaceholder>
+    </>
+  );
+};
+
+interface DeletePreviewEnvironmentModalProps {
+  repoName: string;
+  repoOwner: string;
+  prompt: string;
+  setPrompt: (prompt: string) => void;
+  onDelete: () => void;
+  onClose: () => void;
+  disabled: boolean;
+}
+
+const DeletePreviewEnvironmentModal = (
+  props: DeletePreviewEnvironmentModalProps
+) => {
+  return (
+    <Modal
+      height="fit-content"
+      title={`Remove Preview Envs for ${props.repoOwner}/${props.repoName}`}
+      onRequestClose={props.onClose}
+    >
+      <DeletePreviewEnvironmentModalContentsWrapper>
+        <Banner type="warning">
+          All Preview Environment deployments associated with this repo will be
+          deleted.
+        </Banner>
+        <InputRow
+          type="text"
+          label={`Enter ${props.repoOwner}/${props.repoName} to delete Preview Environments:`}
+          value={props.prompt}
+          placeholder={`${props.repoOwner}/${props.repoName}`}
+          setValue={(x: string) => props.setPrompt(x)}
+          width={"500px"}
+        />
+        <Flex justifyContent="center" alignItems="center">
+          <DeleteButton
+            onClick={() => props.onDelete()}
+            disabled={props.disabled}
+          >
+            Delete
+          </DeleteButton>
+        </Flex>
+      </DeletePreviewEnvironmentModalContentsWrapper>
+    </Modal>
+  );
+};
+
+export default EnvironmentSettings;
+
+const DeletePreviewEnvironmentModalContentsWrapper = styled.div`
+  margin-block-start: 25px;
+`;
+
+const SavePreviewEnvironmentSettings = styled(SaveButton)`
+  margin-top: 30px;
+`;
+
+const Flex = styled.div<{
+  justifyContent?: string;
+  alignItems?: string;
+}>`
+  display: flex;
+  align-items: ${({ alignItems }) => alignItems || "flex-start"};
+  justify-content: ${({ justifyContent }) => justifyContent || "flex-start"};
+`;
+
+const DeleteButton = styled.button<{ disabled?: boolean }>`
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 10px 15px;
+  margin-top: 20px;
+  text-align: left;
+  border-radius: 5px;
+  user-select: none;
+  background: #b91133;
+  border: none;
+  cursor: ${({ disabled }) => (disabled ? "not-allowed" : "pointer")};
+  filter: ${({ disabled }) => (disabled ? "brightness(0.8)" : "none")};
+
+  &:focus {
+    outline: 0;
+  }
+  &:hover {
+    filter: ${({ disabled }) => (disabled ? "brightness(0.8)" : "none")};
+  }
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 2px;
+`;
+
+const StyledPlaceholder = styled.div`
+  width: 100%;
+  padding: 30px;
+  font-size: 13px;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+`;
+
+const Slash = styled.div`
+  margin: 0 4px;
+  color: #aaaabb88;
+`;
+
+const Wrap = styled.div`
+  z-index: 999;
+`;
+
+const ArrowIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+  opacity: 50%;
+`;
+
+const Icon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+`;
+
+const BreadcrumbRow = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: flex-start;
+  margin-bottom: 15px;
+  margin-top: -5px;
+  align-items: center;
+`;
+
+const Breadcrumb = styled(DynamicLink)`
+  color: #aaaabb88;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  z-index: 999;
+  padding: 5px;
+  padding-right: 7px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const WarningBannerWrapper = styled.div`
+  margin-block: 20px;
+`;

+ 31 - 51
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx

@@ -8,6 +8,7 @@ import ButtonEnablePREnvironments from "../components/ButtonEnablePREnvironments
 import { PreviewEnvironmentsHeader } from "../components/PreviewEnvironmentsHeader";
 import { Environment } from "../types";
 import EnvironmentCard from "./EnvironmentCard";
+import Placeholder from "components/Placeholder";
 
 const EnvironmentsList = () => {
   const { currentCluster, currentProject } = useContext(Context);
@@ -80,29 +81,34 @@ const EnvironmentsList = () => {
     <>
       <PreviewEnvironmentsHeader />
       <Relative>
-        {isLoading || !buttonIsReady ? (
-          <FloatingPlaceholder>
-            <Loading />
-          </FloatingPlaceholder>
-        ) : null}
-
         <ControlRow>
           <ButtonEnablePREnvironments setIsReady={setButtonIsReady} />
         </ControlRow>
-        {environments.length === 0 ? (
-          <Placeholder>
-            No repositories found with Preview Environments enabled.
-          </Placeholder>
+        {isLoading ? (
+          <LoadingWrapper>
+            <Loading />
+          </LoadingWrapper>
         ) : (
-          <EnvironmentsGrid>
-            {environments.map((env) => (
-              <EnvironmentCard
-                key={env.id}
-                environment={env}
-                onDelete={removeEnvironmentFromList}
-              />
-            ))}
-          </EnvironmentsGrid>
+          <>
+            {environments.length === 0 ? (
+              <Placeholder
+                title="No repositories found"
+                height="calc(100vh - 400px)"
+              >
+                No repositories were found with Preview Environments enabled.
+              </Placeholder>
+            ) : (
+              <EnvironmentsGrid>
+                {environments.map((env) => (
+                  <EnvironmentCard
+                    key={env.id}
+                    environment={env}
+                    onDelete={removeEnvironmentFromList}
+                  />
+                ))}
+              </EnvironmentsGrid>
+            )}
+          </>
         )}
       </Relative>
     </>
@@ -111,44 +117,18 @@ const EnvironmentsList = () => {
 
 export default EnvironmentsList;
 
-const Relative = styled.div`
-  position: relative;
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
 `;
 
-const Placeholder = styled.div`
-  padding: 30px;
-  margin-top: 35px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 400px;
-  height: 50vh;
-  background: #ffffff11;
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const FloatingPlaceholder = styled(Placeholder)`
-  position: absolute;
-  width: 100%;
-  height: 100%;
-  margin-top: 0px;
+const Relative = styled.div`
+  position: relative;
 `;
 
 const EnvironmentsGrid = styled.div`
-  margin-top: 32px;
   padding-bottom: 150px;
   display: grid;
-  grid-row-gap: 25px;
+  grid-row-gap: 15px;
 `;
 
 const ControlRow = styled.div`
@@ -156,7 +136,7 @@ const ControlRow = styled.div`
   margin-left: auto;
   justify-content: space-between;
   align-items: center;
-  margin: 35px 0;
+  margin: 35px 0 30px;
   padding-left: 0px;
 `;
 

+ 3 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/errors.ts

@@ -0,0 +1,3 @@
+export enum PorterYAMLErrors {
+  FileNotFound = "porter.yaml does not exist in the root of this repository",
+}

+ 13 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx

@@ -5,6 +5,8 @@ import ConnectNewRepo from "./ConnectNewRepo";
 import DeploymentDetail from "./deployments/DeploymentDetail";
 import DeploymentList from "./deployments/DeploymentList";
 import EnvironmentsList from "./environments/EnvironmentsList";
+import EnvironmentSettings from "./environments/EnvironmentSettings";
+import DeployEnvironment from "./environments/CreateEnvironment";
 
 export const Routes = () => {
   const { path } = useRouteMatch();
@@ -20,9 +22,19 @@ export const Routes = () => {
         <Route path={`${path}/connect-repo`}>
           <ConnectNewRepo />
         </Route>
-        <Route path={`${path}/details/:namespace?`}>
+        <Route path={`${path}/details/:id`}>
           <DeploymentDetail />
         </Route>
+        <Route
+          path={`${path}/deployments/:environment_id/:repo_owner/:repo_name/settings`}
+        >
+          <EnvironmentSettings />
+        </Route>
+        <Route
+          path={`${path}/deployments/:environment_id/:repo_owner/:repo_name/create`}
+        >
+          <DeployEnvironment />
+        </Route>
         <Route
           path={`${path}/deployments/:environment_id/:repo_owner/:repo_name`}
         >

+ 6 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts

@@ -29,6 +29,8 @@ export type PRDeployment = {
   gh_pr_branch_into?: string;
 };
 
+export type EnvironmentDeploymentMode = "manual" | "auto";
+
 export type Environment = {
   id: number;
   project_id: number;
@@ -37,9 +39,12 @@ export type Environment = {
   name: string;
   git_repo_owner: string;
   git_repo_name: string;
+  git_repo_branches: string[];
+  new_comments_disabled: boolean;
   last_deployment_status: DeploymentStatusUnion;
   deployment_count: number;
-  mode: "manual" | "auto";
+  mode: EnvironmentDeploymentMode;
+  namespace_annotations: Record<string, string>;
 };
 
 export type PullRequest = {

+ 50 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/utils.ts

@@ -0,0 +1,50 @@
+import api from "shared/api";
+
+interface ValidatePorterYAMLProps {
+  projectID: number;
+  clusterID: number;
+  environmentID: number;
+  branch?: string;
+}
+
+export const validatePorterYAML = ({
+  projectID,
+  clusterID,
+  environmentID,
+  branch,
+}: ValidatePorterYAMLProps) => {
+  return api.validatePorterYAML(
+    "<token>",
+    {
+      ...(branch ? { branch } : {}),
+    },
+    {
+      project_id: projectID,
+      cluster_id: clusterID,
+      environment_id: environmentID,
+    }
+  );
+};
+
+interface GetPRDeploymentListProps {
+  projectID: number;
+  clusterID: number;
+  environmentID: number;
+}
+
+export const getPRDeploymentList = ({
+  clusterID,
+  projectID,
+  environmentID,
+}: GetPRDeploymentListProps) => {
+  return api.getPRDeploymentList(
+    "<token>",
+    {
+      environment_id: environmentID,
+    },
+    {
+      project_id: projectID,
+      cluster_id: clusterID,
+    }
+  );
+};

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -1,5 +1,5 @@
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import TabSelector from "components/TabSelector";
 import TitleSection from "components/TitleSection";
 import React, { useContext, useState } from "react";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx

@@ -3,7 +3,7 @@ import api from "shared/api";
 import { PorterTemplate } from "shared/types";
 import semver from "semver";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import { BackButton, Card } from "../../launch/components/styles";
 import DynamicLink from "components/DynamicLink";
 import { VersionSelector } from "../../launch/components/VersionSelector";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/Store.tsx

@@ -1,5 +1,5 @@
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import React, { createContext, useContext, useEffect, useState } from "react";
 import { useParams } from "react-router";
 import api from "shared/api";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx

@@ -5,7 +5,7 @@ import { Card } from "../../launch/components/styles";
 import { Stack } from "../../types";
 import sliders from "assets/sliders.svg";
 import DynamicLink from "components/DynamicLink";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import Loading from "components/Loading";
 import { useRouteMatch } from "react-router";
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx

@@ -3,7 +3,7 @@ import Loading from "components/Loading";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import styled from "styled-components";
 import { Stack } from "./types";
 import { readableDate } from "shared/string_utils";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx

@@ -156,7 +156,7 @@ const Overview = () => {
         Env Groups
         {/* <InlineDocsHelper
           disableMargin={true}
-          tooltipText="Environment Groups"
+          tooltipText="Environment groups"
           link="https://docs.porter.run/deploying-applications/environment-groups"
         /> */}
       </Heading>

+ 17 - 10
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -13,11 +13,11 @@ import TabRegion from "components/TabRegion";
 import Provisioner from "../provisioner/Provisioner";
 import FormDebugger from "components/porter-form/FormDebugger";
 import TitleSection from "components/TitleSection";
-import Banner from "components/Banner";
 
 import { pushFiltered, pushQueryParams } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { StatusPage } from "../onboarding/steps/ProvisionResources/forms/StatusPage";
+import Banner from "components/Banner";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -225,19 +225,26 @@ const Br = styled.div`
   height: 1px;
 `;
 
-const Code = styled.div`
-  font-family: monospace;
-  margin: 0 7px;
-`;
-
 const DashboardWrapper = styled.div`
   padding-bottom: 100px;
 `;
 
-const A = styled.a`
-  margin-left: 10px;
-  color: #8590ff;
-`;
+// const Banner = styled.div<{ color: string }>`
+//   height: 40px;
+//   width: 100%;
+//   margin: 5px 0 30px;
+//   font-size: 13px;
+//   display: flex;
+//   border-radius: 5px;
+//   padding-left: 15px;
+//   align-items: center;
+//   background: #ffffff11;
+//   color: ${(props) => props.color};
+//   > i {
+//     margin-right: 10px;
+//     font-size: 18px;
+//   }
+// `;
 
 const TopRow = styled.div`
   display: flex;

+ 1 - 1
dashboard/src/main/home/infrastructure/ExpandedInfra.tsx

@@ -10,7 +10,7 @@ import DeployList from "./components/DeployList";
 import InfraResourceList from "./components/InfraResourceList";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import { readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import Header from "components/expanded-object/Header";
 import { Infrastructure, KindMap, Operation } from "shared/types";
 import InfraSettings from "./components/InfraSettings";

+ 2 - 2
dashboard/src/main/home/infrastructure/InfrastructureList.tsx

@@ -7,7 +7,7 @@ import { pushFiltered } from "shared/routing";
 
 import { Column } from "react-table";
 import styled from "styled-components";
-import Table from "components/Table";
+import Table from "components/OldTable";
 
 import Loading from "components/Loading";
 
@@ -15,7 +15,7 @@ import _ from "lodash";
 import { integrationList } from "shared/common";
 import { Infrastructure, KindMap } from "shared/types";
 import { capitalize, readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import SaveButton from "components/SaveButton";
 import { useRouting } from "shared/routing";
 import Description from "components/Description";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/DeployList.tsx

@@ -10,7 +10,7 @@ import {
   OperationType,
 } from "shared/types";
 import { readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import ExpandedOperation from "./ExpandedOperation";
 

+ 1 - 1
dashboard/src/main/home/infrastructure/components/ExpandedOperation.tsx

@@ -10,7 +10,7 @@ import {
   OperationType,
 } from "shared/types";
 import { readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/InfraResourceList.tsx

@@ -4,7 +4,7 @@ import api from "shared/api";
 import styled from "styled-components";
 import Loading from "components/Loading";
 import { TFState } from "shared/types";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 
 type Props = {
   infra_id: number;

+ 1 - 1
dashboard/src/main/home/infrastructure/components/ProvisionInfra.tsx

@@ -8,7 +8,7 @@ import Loading from "components/Loading";
 import TitleSection from "components/TitleSection";
 
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import AWSCredentialsList from "./credentials/AWSCredentialList";
 import Heading from "components/form-components/Heading";
 import GCPCredentialsList from "./credentials/GCPCredentialList";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/AWSCredentialForm.tsx

@@ -9,7 +9,7 @@ import styled from "styled-components";
 import Loading from "components/Loading";
 import { Operation, OperationStatus, OperationType } from "shared/types";
 import { readableDate } from "shared/string_utils";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 
 type Props = {
   setCreatedCredential: (aws_integration_id: number) => void;

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/AWSCredentialList.tsx

@@ -3,7 +3,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import AWSCredentialForm from "./AWSCredentialForm";
 import CredentialList from "./CredentialList";
 import Description from "components/Description";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialForm.tsx

@@ -6,7 +6,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 
 type Props = {
   setCreatedCredential: (aws_integration_id: number) => void;

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialList.tsx

@@ -3,7 +3,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import AzureCredentialForm from "./AzureCredentialForm";
 import CredentialList from "./CredentialList";
 import Description from "components/Description";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/ClusterList.tsx

@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useState } from "react";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import Description from "components/Description";
 import { ClusterType } from "shared/types";
 import SelectRow from "components/form-components/SelectRow";

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/DOCredentialList.tsx

@@ -3,7 +3,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import CredentialList from "./CredentialList";
 import Description from "components/Description";
 

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/GCPCredentialForm.tsx

@@ -6,7 +6,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import Helper from "components/form-components/Helper";
 import UploadArea from "components/form-components/UploadArea";
 

+ 1 - 1
dashboard/src/main/home/infrastructure/components/credentials/GCPCredentialList.tsx

@@ -3,7 +3,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import GCPCredentialForm from "./GCPCredentialForm";
 import CredentialList from "./CredentialList";
 import Description from "components/Description";

+ 2 - 2
dashboard/src/main/home/navbar/Feedback.tsx

@@ -261,7 +261,7 @@ const FeedbackButton = styled(NavButton)`
   color: ${(props: { selected?: boolean }) =>
     props.selected ? "#ffffff" : "#ffffff88"};
   font-family: "Work Sans", sans-serif;
-  font-size: 14px;
+  font-size: 13px;
   margin-right: 20px;
   :hover {
     color: #ffffff;
@@ -276,7 +276,7 @@ const FeedbackButton = styled(NavButton)`
     > i {
       color: ${(props: { selected?: boolean }) =>
         props.selected ? "#ffffff" : "#ffffff88"};
-      font-size: 26px;
+      font-size: 23px;
       margin-right: 6px;
     }
   }

+ 3 - 2
dashboard/src/main/home/navbar/Help.tsx

@@ -198,7 +198,7 @@ const FeedbackButton = styled(NavButton)`
   color: ${(props: { selected?: boolean }) =>
     props.selected ? "#ffffff" : "#ffffff88"};
   font-family: "Work Sans", sans-serif;
-  font-size: 14px;
+  font-size: 13px;
   margin-right: 20px;
   :hover {
     color: #ffffff;
@@ -213,8 +213,9 @@ const FeedbackButton = styled(NavButton)`
     > i {
       color: ${(props: { selected?: boolean }) =>
         props.selected ? "#ffffff" : "#ffffff88"};
-      font-size: 22px;
+      font-size: 18px;
       margin-right: 6px;
+      margin-bottom: -1px;
     }
   }
 `;

+ 3 - 3
dashboard/src/main/home/navbar/Navbar.tsx

@@ -211,7 +211,7 @@ const Dropdown = styled.div`
 `;
 
 const StyledNavbar = styled.div`
-  height: 60px;
+  height: 50px;
   position: absolute;
   top: 0;
   right: 0;
@@ -229,7 +229,7 @@ const NavButton = styled.a`
   color: #ffffff88;
   cursor: pointer;
   justify-content: center;
-  margin-right: 15px;
+  margin-right: 10px;
   :hover {
     > i {
       color: #ffffff;
@@ -241,6 +241,6 @@ const NavButton = styled.a`
     cursor: pointer;
     color: ${(props: { selected?: boolean }) =>
       props.selected ? "#ffffff" : "#ffffff88"};
-    font-size: 24px;
+    font-size: 20px;
   }
 `;

+ 1 - 1
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -20,7 +20,7 @@ import { provisionResourcesTracks } from "shared/anayltics";
 import DocsHelper from "components/DocsHelper";
 import Description from "components/Description";
 import api from "shared/api";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import Loading from "components/Loading";
 import MultiSaveButton from "components/MultiSaveButton";
 import buildLogger from "shared/error_handling/logger";

+ 1 - 1
dashboard/src/main/home/project-settings/APITokensSection.tsx

@@ -11,7 +11,7 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import RadioSelector from "components/RadioSelector";
 import CreateAPITokenForm from "./api-tokens/CreateAPITokenForm";
 import TokenList from "./api-tokens/TokenList";

+ 1 - 1
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -11,7 +11,7 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import RadioSelector from "components/RadioSelector";
 
 type Props = {};

+ 1 - 1
dashboard/src/main/home/project-settings/api-tokens/CreateAPITokenForm.tsx

@@ -12,7 +12,7 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import RadioSelector from "components/RadioSelector";
 import SelectRow from "components/form-components/SelectRow";
 import SaveButton from "components/SaveButton";

+ 1 - 1
dashboard/src/main/home/project-settings/api-tokens/CustomPolicyForm.tsx

@@ -12,7 +12,7 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
-import Table from "components/Table";
+import Table from "components/OldTable";
 import RadioSelector from "components/RadioSelector";
 import SelectRow from "components/form-components/SelectRow";
 import SaveButton from "components/SaveButton";

+ 1 - 1
dashboard/src/main/home/project-settings/api-tokens/TokenList.tsx

@@ -1,6 +1,6 @@
 import Description from "components/Description";
 import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import Placeholder from "components/OldPlaceholder";
 import React, { useContext, useEffect, useState } from "react";
 import { Context } from "shared/Context";
 

+ 63 - 25
dashboard/src/shared/api.tsx

@@ -143,6 +143,9 @@ const createEnvironment = baseApi<
   {
     name: string;
     mode: "auto" | "manual";
+    disable_new_comments: boolean;
+    git_repo_branches: string[];
+    namespace_annotations: Record<string, string>;
   },
   {
     project_id: number;
@@ -162,6 +165,24 @@ const createEnvironment = baseApi<
   return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/environment`;
 });
 
+const updateEnvironment = baseApi<
+  {
+    mode: "auto" | "manual";
+    disable_new_comments: boolean;
+    git_repo_branches: string[]; // Array with branch names
+    namespace_annotations: Record<string, string>;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    environment_id: number;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, environment_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/settings`
+);
+
 const deleteEnvironment = baseApi<
   {
     name: string;
@@ -243,6 +264,20 @@ const toggleNewCommentForEnvironment = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/toggle_new_comment`;
 });
 
+const validatePorterYAML = baseApi<
+  {
+    branch?: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    environment_id: number;
+  }
+>("GET", (pathParams) => {
+  const { project_id, cluster_id, environment_id } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/validate_porter_yaml`;
+});
+
 const createGCPIntegration = baseApi<
   {
     gcp_key_data: string;
@@ -404,9 +439,9 @@ const getPRDeploymentList = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployments`;
 });
 
-const getPRDeploymentByEnvironment = baseApi<
+const getPRDeploymentByID = baseApi<
   {
-    namespace: string;
+    id: number;
   },
   {
     cluster_id: number;
@@ -419,27 +454,28 @@ const getPRDeploymentByEnvironment = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/deployment`;
 });
 
-const getPRDeployment = baseApi<
-  {
-    namespace: string;
-  },
-  {
-    cluster_id: number;
-    project_id: number;
-    git_installation_id: number;
-    git_repo_owner: string;
-    git_repo_name: string;
-  }
->("GET", (pathParams) => {
-  const {
-    cluster_id,
-    project_id,
-    git_installation_id,
-    git_repo_owner,
-    git_repo_name,
-  } = pathParams;
-  return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/deployment`;
-});
+// TODO (soham): Check if we are really using this?
+// const getPRDeployment = baseApi<
+//   {
+//     namespace: string;
+//   },
+//   {
+//     cluster_id: number;
+//     project_id: number;
+//     git_installation_id: number;
+//     git_repo_owner: string;
+//     git_repo_name: string;
+//   }
+// >("GET", (pathParams) => {
+//   const {
+//     cluster_id,
+//     project_id,
+//     git_installation_id,
+//     git_repo_owner,
+//     git_repo_name,
+//   } = pathParams;
+//   return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/deployment`;
+// });
 
 const deletePRDeployment = baseApi<
   {},
@@ -2261,12 +2297,14 @@ export default {
   createGitlabIntegration,
   createEmailVerification,
   createEnvironment,
+  updateEnvironment,
   deleteEnvironment,
   createPreviewEnvironmentDeployment,
   reenablePreviewEnvironmentDeployment,
   listEnvironments,
   getEnvironment,
   toggleNewCommentForEnvironment,
+  validatePorterYAML,
   createGCPIntegration,
   createInvite,
   createNamespace,
@@ -2307,8 +2345,8 @@ export default {
   getClusterNode,
   getConfigMap,
   getPRDeploymentList,
-  getPRDeploymentByEnvironment,
-  getPRDeployment,
+  getPRDeploymentByID,
+  // getPRDeployment,
   getGHAWorkflowTemplate,
   getGitRepoList,
   getGitRepoPermission,

+ 3 - 0
dashboard/src/shared/routing.tsx

@@ -91,6 +91,9 @@ export const useRouting = () => {
   const history = useHistory();
 
   return {
+    push(path: string, state?: any) {
+      history.push(path, state);
+    },
     pushQueryParams: (
       params: { [key: string]: unknown },
       removedParams?: string[]

+ 13 - 0
dashboard/src/shared/search.ts

@@ -0,0 +1,13 @@
+import Fuse from "fuse.js";
+
+export const search = <T>(
+  items: T[],
+  searchTerm: string,
+  options?: Fuse.IFuseOptions<T>
+) => {
+  if (!searchTerm) {
+    return items;
+  }
+  const fuse = new Fuse<T>(items, options);
+  return fuse.search(searchTerm).map((result) => result.item);
+};

+ 4 - 0
dashboard/src/shared/types.tsx

@@ -312,6 +312,10 @@ export type ActionConfigType = {
     }
 );
 
+export type GithubActionConfigType = ActionConfigType & {
+  kind: "github";
+};
+
 export type FullActionConfigType = ActionConfigType & {
   dockerfile_path: string;
   folder_path: string;

+ 27 - 0
internal/models/environment.go

@@ -17,6 +17,7 @@ type Environment struct {
 	GitInstallationID uint
 	GitRepoOwner      string
 	GitRepoName       string
+	GitRepoBranches   string
 
 	Name string
 	Mode string
@@ -31,6 +32,24 @@ type Environment struct {
 	GithubWebhookID int64
 }
 
+func getGitRepoBranches(branches string) []string {
+	var branchesArr []string
+
+	if branches != "" {
+		supposedBranches := strings.Split(branches, ",")
+
+		for _, br := range supposedBranches {
+			name := strings.TrimSpace(br)
+
+			if len(name) > 0 {
+				branchesArr = append(branchesArr, name)
+			}
+		}
+	}
+
+	return branchesArr
+}
+
 func (e *Environment) ToEnvironmentType() *types.Environment {
 	env := &types.Environment{
 		ID:                e.Model.ID,
@@ -47,6 +66,14 @@ func (e *Environment) ToEnvironmentType() *types.Environment {
 		Mode: e.Mode,
 	}
 
+	branches := getGitRepoBranches(e.GitRepoBranches)
+
+	if len(branches) > 0 {
+		env.GitRepoBranches = branches
+	} else {
+		env.GitRepoBranches = []string{}
+	}
+
 	if len(e.NamespaceAnnotations) > 0 {
 		env.NamespaceAnnotations = make(map[string]string)
 		annotations := string(e.NamespaceAnnotations)