瀏覽代碼

create env template flow (#4643)

ianedwards 2 年之前
父節點
當前提交
5c0ddace1f
共有 38 個文件被更改,包括 1740 次插入677 次删除
  1. 126 0
      api/server/handlers/porter_app/templates_list.go
  2. 29 0
      api/server/router/porter_app.go
  3. 7 7
      dashboard/package-lock.json
  4. 1 1
      dashboard/package.json
  5. 1 0
      dashboard/src/assets/plus-square.svg
  6. 12 0
      dashboard/src/lib/environments/types.ts
  7. 87 0
      dashboard/src/lib/hooks/useTemplateEnvs.ts
  8. 4 1
      dashboard/src/lib/porter-apps/build.ts
  9. 38 2
      dashboard/src/lib/porter-apps/index.ts
  10. 12 2
      dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx
  11. 43 6
      dashboard/src/main/home/app-dashboard/apps/AppMeta.tsx
  12. 1 1
      dashboard/src/main/home/app-dashboard/apps/Apps.tsx
  13. 6 1
      dashboard/src/main/home/app-dashboard/apps/SelectableAppList.tsx
  14. 20 44
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  15. 34 35
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  16. 94 40
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppList.tsx
  17. 69 61
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppRow.tsx
  18. 5 8
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/DeleteEnvModal.tsx
  19. 306 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/EnvTemplateContextProvider.tsx
  20. 33 50
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx
  21. 14 9
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx
  22. 3 3
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/Addons.tsx
  23. 176 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/AppSelector.tsx
  24. 54 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/ConsolidatedServices.tsx
  25. 219 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/CreateTemplate.tsx
  26. 29 260
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer.tsx
  27. 11 12
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewGHAModal.tsx
  28. 50 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/RevisionLoader.tsx
  29. 42 25
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/SetupApp.tsx
  30. 5 4
      dashboard/src/main/home/managed-addons/AddonListRow.tsx
  31. 1 1
      dashboard/src/main/home/managed-addons/AddonsList.tsx
  32. 93 48
      dashboard/src/main/home/managed-addons/tabs/PostgresTabs.tsx
  33. 93 48
      dashboard/src/main/home/managed-addons/tabs/RedisTabs.tsx
  34. 2 2
      dashboard/src/main/home/sidebar/Sidebar.tsx
  35. 6 3
      dashboard/src/shared/DeploymentTargetContext.tsx
  36. 11 0
      dashboard/src/shared/api.tsx
  37. 1 1
      go.mod
  38. 2 2
      go.sum

+ 126 - 0
api/server/handlers/porter_app/templates_list.go

@@ -0,0 +1,126 @@
+package porter_app
+
+import (
+	"context"
+	"encoding/base64"
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"google.golang.org/protobuf/reflect/protoreflect"
+)
+
+// ListEnvironmentTemplatesHandler handles requests to the /apps/templates endpoint
+type ListEnvironmentTemplatesHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListEnvironmentTemplatesHandler returns a new ListEnvironmentTemplatesHandler
+func NewListEnvironmentTemplatesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListEnvironmentTemplatesHandler {
+	return &ListEnvironmentTemplatesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// Environment is a partially encoded environment object
+type Environment struct {
+	// Name is the name of the environment
+	Name string `json:"name"`
+	// Base64 Apps is a list of apps that are deployed in the environment
+	Base64Apps []string `json:"base64_apps,omitempty"`
+	// Base64Addons is a list of encoded addons that are deployed in the environment
+	Base64Addons []string `json:"base64_addons,omitempty"`
+}
+
+// ListEnvironmentTemplatesResponse represents the response from the /apps/templates endpoint
+type ListEnvironmentTemplatesResponse struct {
+	EnvironmentTemplates []Environment `json:"environment_templates,omitempty"`
+}
+
+// ServeHTTP lists all environment templates
+func (c *ListEnvironmentTemplatesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-environment-templates")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	listTemplatesReq := connect.NewRequest(&porterv1.ListTemplatesRequest{
+		ProjectId: int64(project.ID),
+		ClusterId: int64(cluster.ID),
+	})
+
+	listTemplatesResp, err := c.Config().ClusterControlPlaneClient.ListTemplates(ctx, listTemplatesReq)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error listing templates")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if listTemplatesResp == nil || listTemplatesResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "list templates response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	// get all apps for each environment
+	var envTemplates []Environment
+
+	for _, env := range listTemplatesResp.Msg.EnvironmentTemplates {
+		var encodedApps []string
+		for _, app := range env.Apps {
+			encoded, err := base64EncodeContractObject(ctx, app)
+			if err != nil {
+				err = telemetry.Error(ctx, span, err, "error encoding app")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+
+			encodedApps = append(encodedApps, encoded)
+		}
+
+		var encodedAddons []string
+		for _, addon := range env.Addons {
+			encoded, err := base64EncodeContractObject(ctx, addon)
+			if err != nil {
+				err = telemetry.Error(ctx, span, err, "error encoding addon")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+
+			encodedAddons = append(encodedAddons, encoded)
+		}
+
+		envTemplates = append(envTemplates, Environment{
+			Name:         env.Name,
+			Base64Apps:   encodedApps,
+			Base64Addons: encodedAddons,
+		})
+	}
+
+	res := ListEnvironmentTemplatesResponse{
+		EnvironmentTemplates: envTemplates,
+	}
+
+	c.WriteResult(w, r, res)
+}
+
+func base64EncodeContractObject(ctx context.Context, pc protoreflect.ProtoMessage) (string, error) {
+	by, err := helpers.MarshalContractObject(ctx, pc)
+	if err != nil {
+		return "", err
+	}
+
+	return base64.StdEncoding.EncodeToString(by), nil
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -1589,6 +1589,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/templates -> porter_app.NewListEnvironmentTemplatesHandler
+	listEnvironmentTemplatesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/templates", relPathV2),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listEnvironmentTemplatesHandler := porter_app.NewListEnvironmentTemplatesHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listEnvironmentTemplatesEndpoint,
+		Handler:  listEnvironmentTemplatesHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/templates -> porter_app.NewGetAppTemplateHandler
 	getAppTemplateEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 7 - 7
dashboard/package-lock.json

@@ -100,7 +100,7 @@
         "@babel/preset-typescript": "^7.15.0",
         "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
         "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-        "@porter-dev/api-contracts": "^0.2.155",
+        "@porter-dev/api-contracts": "^0.2.164",
         "@testing-library/jest-dom": "^4.2.4",
         "@testing-library/react": "^9.3.2",
         "@testing-library/user-event": "^7.1.2",
@@ -2077,9 +2077,9 @@
       }
     },
     "node_modules/@bufbuild/protobuf": {
-      "version": "1.8.0",
-      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.8.0.tgz",
-      "integrity": "sha512-qR9FwI8QKIveDnUYutvfzbC21UZJJryYrLuZGjeZ/VGz+vXelUkK+xgkOHsvPEdYEdxtgUUq4313N8QtOehJ1Q==",
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.9.0.tgz",
+      "integrity": "sha512-W7gp8Q/v1NlCZLsv8pQ3Y0uCu/SHgXOVFK+eUluUKWXmsb6VHkpNx0apdOWWcDbB9sJoKeP8uPrjmehJz6xETQ==",
       "dev": true
     },
     "node_modules/@discoveryjs/json-ext": {
@@ -2786,9 +2786,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.2.155",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.155.tgz",
-      "integrity": "sha512-Tar/IsKoUSmz8Q8Fw9ozflrAI+yAGzOIdx5WmZ5iCSCkvudSLnDp7xQ0po/traPzYLUldZjaNsw0KKXnOb1myQ==",
+      "version": "0.2.164",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.164.tgz",
+      "integrity": "sha512-yq3rX6YVbTFCTh4p1UdXNSUHsSD/ED0M5JdhaRee9PRBAJ5wsNg2FUXN4zYcOM3e5qqmgRwF25RFc9jm4wKthQ==",
       "dev": true,
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"

+ 1 - 1
dashboard/package.json

@@ -107,7 +107,7 @@
     "@babel/preset-typescript": "^7.15.0",
     "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
     "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-    "@porter-dev/api-contracts": "^0.2.155",
+    "@porter-dev/api-contracts": "^0.2.164",
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",

+ 1 - 0
dashboard/src/assets/plus-square.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-plus"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>

+ 12 - 0
dashboard/src/lib/environments/types.ts

@@ -0,0 +1,12 @@
+import { z } from "zod";
+
+import { clientAddonValidator } from "lib/addons";
+import { clientAppValidator } from "lib/porter-apps";
+
+const environmentValidator = z.object({
+  name: z.string(),
+  apps: z.array(clientAppValidator),
+  addons: z.array(clientAddonValidator),
+});
+
+export type Environment = z.infer<typeof environmentValidator>;

+ 87 - 0
dashboard/src/lib/hooks/useTemplateEnvs.ts

@@ -0,0 +1,87 @@
+import { useContext, useMemo } from "react";
+import { Addon, PorterApp } from "@porter-dev/api-contracts";
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+
+import { clientAddonFromProto } from "lib/addons";
+import { type Environment } from "lib/environments/types";
+import { clientAppFromProto } from "lib/porter-apps";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+type TUseTemplateEnvs = {
+  environments: Environment[];
+  status: "error" | "success" | "loading";
+};
+
+const listTemplateEnvsResValidator = z.object({
+  name: z.string(),
+  base64_apps: z.string().array().default([]),
+  base64_addons: z.string().array().default([]),
+});
+
+export const useTemplateEnvs = (): TUseTemplateEnvs => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const { data: encodedEnvironments = [], status } = useQuery(
+    ["listTemplateEnvironments", currentProject?.id, currentCluster?.id],
+    async () => {
+      if (!currentProject || !currentCluster) {
+        return [];
+      }
+
+      const res = await api.listTemplateEnvironments(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      const data = await z
+        .object({
+          environment_templates: z
+            .array(listTemplateEnvsResValidator)
+            .default([]),
+        })
+        .parseAsync(res.data);
+      return data.environment_templates;
+    },
+    {
+      enabled: !!currentProject && !!currentCluster,
+    }
+  );
+
+  const environments = useMemo(() => {
+    return encodedEnvironments.map((env) => {
+      const apps = env.base64_apps.map((a) =>
+        clientAppFromProto({
+          proto: PorterApp.fromJsonString(atob(a), {
+            ignoreUnknownFields: true,
+          }),
+          overrides: null,
+        })
+      );
+      const addons = env.base64_addons.map((a) =>
+        clientAddonFromProto({
+          addon: Addon.fromJsonString(atob(a), {
+            ignoreUnknownFields: true,
+          }),
+        })
+      );
+
+      return {
+        name: env.name,
+        apps,
+        addons,
+      };
+    });
+  }, [encodedEnvironments]);
+
+  return {
+    environments,
+    status,
+  };
+};

+ 4 - 1
dashboard/src/lib/porter-apps/build.ts

@@ -1,6 +1,7 @@
-import { buildpackSchema } from "main/home/app-dashboard/types/buildpack";
 import { z } from "zod";
 
+import { buildpackSchema } from "main/home/app-dashboard/types/buildpack";
+
 // buildValidator is used to validate inputs for build setting fields
 export const buildValidator = z.discriminatedUnion("method", [
   z.object({
@@ -8,11 +9,13 @@ export const buildValidator = z.discriminatedUnion("method", [
     context: z.string().min(1).default("./").catch("./"),
     buildpacks: z.array(buildpackSchema).default([]),
     builder: z.string(),
+    repo: z.string().optional(),
   }),
   z.object({
     method: z.literal("docker"),
     context: z.string().min(1).default("./").catch("./"),
     dockerfile: z.string().min(1).default("./Dockerfile").catch("./Dockerfile"),
+    repo: z.string().optional(),
   }),
 ]);
 export type BuildOptions = z.infer<typeof buildValidator>;

+ 38 - 2
dashboard/src/lib/porter-apps/index.ts

@@ -193,6 +193,36 @@ export const porterAppFormValidator = basePorterAppFormValidator
   );
 export type PorterAppFormData = z.infer<typeof porterAppFormValidator>;
 
+export const APP_CREATE_FORM_DEFAULTS = {
+  app: {
+    name: {
+      value: "",
+      readOnly: false,
+    },
+    build: {
+      method: "pack" as const,
+      context: "./",
+      builder: "",
+      buildpacks: [],
+    },
+    env: [],
+    efsStorage: {
+      enabled: false,
+    },
+  },
+  source: {
+    git_repo_name: "",
+    git_branch: "",
+    porter_yaml_path: "",
+  },
+  deletions: {
+    serviceNames: [],
+    envGroupNames: [],
+    predeploy: [],
+    initialDeploy: [],
+  },
+};
+
 // serviceOverrides is used to generate the services overrides for an app from porter.yaml
 // this method is only called when a porter.yaml is present and has services defined
 export function serviceOverrides({
@@ -350,6 +380,7 @@ const clientBuildToProto = (build: BuildOptions): Build => {
           context: b.context,
           buildpacks: b.buildpacks.map((b) => b.buildpack),
           builder: b.builder,
+          repo: b.repo,
         })
     )
     .with(
@@ -359,6 +390,7 @@ const clientBuildToProto = (build: BuildOptions): Build => {
           method: "docker",
           context: b.context,
           dockerfile: b.dockerfile,
+          repo: b.repo,
         })
     )
     .exhaustive();
@@ -479,11 +511,13 @@ const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
         context: z.string(),
         buildpacks: z.array(z.string()).default([]),
         builder: z.string(),
+        repo: z.string().optional(),
       }),
       z.object({
         method: z.literal("docker"),
         context: z.string(),
         dockerfile: z.string(),
+        repo: z.string().optional(),
       }),
     ])
     .safeParse(proto);
@@ -504,6 +538,7 @@ const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
           buildpack: b,
         })),
         builder: b.builder,
+        repo: b.repo,
       })
     )
     .with({ method: "docker" }, (b) =>
@@ -511,6 +546,7 @@ const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
         method: b.method,
         context: b.context,
         dockerfile: b.dockerfile,
+        repo: b.repo,
       })
     )
     .exhaustive();
@@ -612,8 +648,8 @@ export function clientAppFromProto({
       value: proto.name,
     },
     services,
-    predeploy: predeployList.length ? predeployList : undefined,
-    initialDeploy: initialDeployList.length ? initialDeployList : undefined,
+    predeploy: predeployList,
+    initialDeploy: initialDeployList,
     env: parsedEnv,
     envGroups: proto.envGroups.map((eg) => ({
       name: eg.name,

+ 12 - 2
dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx

@@ -134,7 +134,12 @@ const AppGrid: React.FC<AppGridProps> = ({
                   </Container>
                   {/** TODO: make the status icon dynamic */}
                   {/* <StatusIcon src={healthy} /> */}
-                  <AppSource source={source} />
+                  <AppSource
+                    source={{
+                      from: "porter_apps",
+                      details: source,
+                    }}
+                  />
                   {currentProject?.managed_deployment_targets_enabled &&
                     !currentDeploymentTarget?.is_preview && (
                       <Container row>
@@ -188,7 +193,12 @@ const AppGrid: React.FC<AppGridProps> = ({
                   </Container>
                   <Spacer height="15px" />
                   <Container row>
-                    <AppSource source={source} />
+                    <AppSource
+                      source={{
+                        from: "porter_apps",
+                        details: source,
+                      }}
+                    />
                     <Spacer inline x={1} />
                     <SmallIcon opacity="0.4" src={time} />
                     <Text size={13} color="#ffffff44">

+ 43 - 6
dashboard/src/main/home/app-dashboard/apps/AppMeta.tsx

@@ -4,6 +4,7 @@ import styled from "styled-components";
 import Container from "components/porter/Container";
 import Icon from "components/porter/Icon";
 import Text from "components/porter/Text";
+import { type ClientPorterApp } from "lib/porter-apps";
 
 import box from "assets/box.png";
 import git_scm from "assets/git-scm.svg";
@@ -26,13 +27,49 @@ type IconProps = {
 };
 
 type SourceProps = {
-  source: AppRevisionWithSource["source"];
+  source:
+    | {
+        from: "porter_apps";
+        details: AppRevisionWithSource["source"];
+      }
+    | {
+        from: "app_contract";
+        details: ClientPorterApp;
+      };
 };
 
 export const AppSource: React.FC<SourceProps> = ({ source }) => {
+  if (source.from === "app_contract") {
+    const build = source.details.build;
+    const repoFullName = build.repo
+      ? new URL(build.repo).pathname.substring(1)
+      : "";
+    return (
+      <>
+        {build.repo ? (
+          <Container row>
+            <SmallIcon opacity="0.6" src={github} />
+            <Text truncate={true} size={13} color="#ffffff44">
+              {repoFullName.endsWith(".git")
+                ? repoFullName.slice(0, -4)
+                : repoFullName}
+            </Text>
+          </Container>
+        ) : (
+          <Container row>
+            <SmallIcon opacity="0.6" height="18px" src={box} />
+            <Text truncate={true} size={13} color="#ffffff44">
+              {source.details.name.value}
+            </Text>
+          </Container>
+        )}
+      </>
+    );
+  }
+
   return (
     <>
-      {source.image_repo_uri ? (
+      {source.details.image_repo_uri ? (
         <Container row>
           <SmallIcon
             opacity="0.7"
@@ -40,21 +77,21 @@ export const AppSource: React.FC<SourceProps> = ({ source }) => {
             src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
           />
           <Text truncate={true} size={13} color="#ffffff44">
-            {source.image_repo_uri}
+            {source.details.image_repo_uri}
           </Text>
         </Container>
-      ) : source.repo_name ? (
+      ) : source.details.repo_name ? (
         <Container row>
           <SmallIcon opacity="0.6" src={github} />
           <Text truncate={true} size={13} color="#ffffff44">
-            {source.repo_name}
+            {source.details.repo_name}
           </Text>
         </Container>
       ) : (
         <Container row>
           <SmallIcon src={git_scm} />
           <Text truncate={true} size={13} color="#ffffff44">
-            {source.name}
+            {source.details.name}
           </Text>
         </Container>
       )}

+ 1 - 1
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -346,7 +346,7 @@ const Apps: React.FC = () => {
                 }}
               >
                 <div>
-                  {currentDeploymentTarget?.namespace ?? "Preview Apps"}
+                  {currentDeploymentTarget?.namespace ?? "Preview Environments"}
                 </div>
                 <Badge>Preview</Badge>
               </div>

+ 6 - 1
dashboard/src/main/home/app-dashboard/apps/SelectableAppList.tsx

@@ -49,7 +49,12 @@ const SelectableAppList: React.FC<AppListProps> = ({ appListItems }) => {
               </Container>
               <Spacer height="15px" />
               <Container row>
-                <AppSource source={ali.app.source} />
+                <AppSource
+                  source={{
+                    from: "porter_apps",
+                    details: ali.app.source,
+                  }}
+                />
                 <Spacer inline x={1} />
               </Container>
             </>

+ 20 - 44
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -34,6 +34,7 @@ import { useIntercom } from "lib/hooks/useIntercom";
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
 import { checkIfProjectHasPayment } from "lib/hooks/useStripe";
 import {
+  APP_CREATE_FORM_DEFAULTS,
   porterAppFormValidator,
   type PorterAppFormData,
   type SourceOptions,
@@ -139,35 +140,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   const porterAppFormMethods = useForm<PorterAppFormData>({
     resolver: zodResolver(porterAppFormValidator),
     reValidateMode: "onSubmit",
-    defaultValues: {
-      app: {
-        name: {
-          value: "",
-          readOnly: false,
-        },
-        build: {
-          method: "pack",
-          context: "./",
-          builder: "",
-          buildpacks: [],
-        },
-        env: [],
-        efsStorage: {
-          enabled: false,
-        },
-      },
-      source: {
-        git_repo_name: "",
-        git_branch: "",
-        porter_yaml_path: "",
-      },
-      deletions: {
-        serviceNames: [],
-        envGroupNames: [],
-        predeploy: [],
-        initialDeploy: [],
-      },
-    },
+    defaultValues: APP_CREATE_FORM_DEFAULTS,
   });
   const {
     register,
@@ -437,7 +410,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
       let stringifiedJson = "unable to stringify errors";
       try {
         stringifiedJson = JSON.stringify(errors);
-      } catch (e) { }
+      } catch (e) {}
       void updateAppStep({
         step: "stack-launch-failure",
         errorMessage: `Form validation error (visible to user): ${errorMessage}. Stringified JSON errors (invisible to user): ${stringifiedJson}`,
@@ -546,8 +519,8 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                     <Text
                       color={
                         isNameHighlight &&
-                          porterAppFormMethods.getValues("app.name.value")
-                            .length > 0
+                        porterAppFormMethods.getValues("app.name.value")
+                          .length > 0
                           ? "#FFCC00"
                           : "helper"
                       }
@@ -682,8 +655,9 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                             }
                           >
                             {detectedServices.count > 0
-                              ? `Detected ${detectedServices.count} service${detectedServices.count > 1 ? "s" : ""
-                              } from porter.yaml.`
+                              ? `Detected ${detectedServices.count} service${
+                                  detectedServices.count > 1 ? "s" : ""
+                                } from porter.yaml.`
                               : `Could not detect any services from porter.yaml. Make sure it exists in the root of your repo.`}
                           </Text>
                         </AppearingDiv>
@@ -778,16 +752,18 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
           }}
         />
       )}
-      {currentProject?.sandbox_enabled && currentProject?.billing_enabled && !hasPaymentEnabled && (
-        <BillingModal
-          back={() => {
-            history.push("/apps");
-          }}
-          onCreate={async () => {
-            history.push("/apps/new/app");
-          }}
-        />
-      )}
+      {currentProject?.sandbox_enabled &&
+        currentProject?.billing_enabled &&
+        !hasPaymentEnabled && (
+          <BillingModal
+            back={() => {
+              history.push("/apps");
+            }}
+            onCreate={async () => {
+              history.push("/apps/new/app");
+            }}
+          />
+        )}
     </CenterWrapper>
   );
 };

+ 34 - 35
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -1,27 +1,29 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
-import { Context } from "shared/Context";
-import api from "shared/api";
-import styled from "styled-components";
-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 styled from "styled-components";
+
 import DynamicLink from "components/DynamicLink";
-import DashboardHeader from "../../DashboardHeader";
-import RadioFilter from "components/RadioFilter";
+import Loading from "components/Loading";
 import Placeholder from "components/Placeholder";
 import Banner from "components/porter/Banner";
+import RadioFilter from "components/RadioFilter";
 
-import pullRequestIcon from "assets/pull_request_icon.svg";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import { search } from "shared/search";
 import filterOutline from "assets/filter-outline.svg";
+import pullRequestIcon from "assets/pull_request_icon.svg";
 import sort from "assets/sort.svg";
-import { search } from "shared/search";
-import { getPRDeploymentList, validatePorterYAML } from "../utils";
-import { PorterYAMLErrors } from "../errors";
+
+import DashboardHeader from "../../DashboardHeader";
 import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
+import { PorterYAMLErrors } from "../errors";
+import { deployments, pull_requests } from "../mocks";
+import { Environment, type PRDeployment, type PullRequest } from "../types";
+import { getPRDeploymentList, validatePorterYAML } from "../utils";
+import DeploymentCard from "./DeploymentCard";
 
 const AvailableStatusFilters = [
   "all",
@@ -32,7 +34,7 @@ const AvailableStatusFilters = [
   "updating",
 ];
 
-type AvailableStatusFiltersType = typeof AvailableStatusFilters[number];
+type AvailableStatusFiltersType = (typeof AvailableStatusFilters)[number];
 
 const DeploymentList = () => {
   const [sortOrder, setSortOrder] = useState("Newest");
@@ -47,14 +49,11 @@ const DeploymentList = () => {
     string[]
   >([]);
 
-  const [
-    statusSelectorVal,
-    setStatusSelectorVal,
-  ] = useState<AvailableStatusFiltersType>("all");
+  const [statusSelectorVal, setStatusSelectorVal] =
+    useState<AvailableStatusFiltersType>("all");
 
-  const { currentProject, currentCluster, setCurrentError } = useContext(
-    Context
-  );
+  const { currentProject, currentCluster, setCurrentError } =
+    useContext(Context);
   const { getQueryParam, pushQueryParams } = useRouting();
   const location = useLocation();
   const history = useHistory();
@@ -66,8 +65,8 @@ const DeploymentList = () => {
 
   const selectedRepo = `${repo_owner}/${repo_name}`;
 
-  const getEnvironment = () => {
-    return api.getEnvironment(
+  const getEnvironment = async () => {
+    return await api.getEnvironment(
       "<token>",
       {},
       {
@@ -241,7 +240,7 @@ const DeploymentList = () => {
       return (
         <Placeholder height="calc(100vh - 400px)">
           No preview developments have been found. Open a PR to create a new
-          preview app.
+          preview environment.
         </Placeholder>
       );
     }
@@ -279,7 +278,9 @@ const DeploymentList = () => {
     <>
       <PorterYAMLErrorsModal
         errors={expandedPorterYAMLErrors}
-        onClose={() => setExpandedPorterYAMLErrors([])}
+        onClose={() => {
+          setExpandedPorterYAMLErrors([]);
+        }}
         repo={selectedRepo}
       />
 
@@ -380,15 +381,13 @@ const DeploymentList = () => {
 
 export default DeploymentList;
 
-const mockRequest = () =>
-  new Promise((res) => {
-    setTimeout(
-      () =>
-        res({
-          data: { deployments: deployments, pull_requests: pull_requests },
-        }),
-      1000
-    );
+const mockRequest = async () =>
+  await new Promise((res) => {
+    setTimeout(() => {
+      res({
+        data: { deployments, pull_requests },
+      });
+    }, 1000);
   });
 
 const LoadingWrapper = styled.div`

+ 94 - 40
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppList.tsx

@@ -1,77 +1,131 @@
-import React, { useContext } from "react";
+import React, { useContext, useMemo, useState } from "react";
 import { useHistory } from "react-router";
 import styled from "styled-components";
 
 import Loading from "components/Loading";
 import Button from "components/porter/Button";
-import Fieldset from "components/porter/Fieldset";
+import Container from "components/porter/Container";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
+import Image from "components/porter/Image";
+import SearchBar from "components/porter/SearchBar";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { useLatestAppRevisions } from "lib/hooks/useLatestAppRevisions";
+import { useAppInstances } from "lib/hooks/useLatestAppRevisions";
+import { useTemplateEnvs } from "lib/hooks/useTemplateEnvs";
 
 import { Context } from "shared/Context";
+import add from "assets/plus-square.svg";
 
 import { ConfigurableAppRow } from "./ConfigurableAppRow";
 
 export const ConfigurableAppList: React.FC = () => {
   const history = useHistory();
   const queryParams = new URLSearchParams(window.location.search);
+  const [searchValue, setSearchValue] = useState("");
 
   const { currentProject, currentCluster } = useContext(Context);
 
-  const { revisions: apps } = useLatestAppRevisions({
+  const { instances: appInstances } = useAppInstances({
     projectId: currentProject?.id ?? 0,
     clusterId: currentCluster?.id ?? 0,
   });
 
-  if (apps.length === 0) {
-    return (
-      <Fieldset>
-        <CentralContainer>
-          <Text size={16}>No apps have been deployed yet.</Text>
-          <Spacer y={1} />
+  const { environments, status } = useTemplateEnvs();
 
-          <Text color={"helper"}>Get started by creating a new app.</Text>
-          <Spacer y={1} />
-          <Button
-            onClick={() => {
-              history.push("/apps/new/app");
-            }}
-          >
-            Create App
-          </Button>
-        </CentralContainer>
-      </Fieldset>
+  const envsWithExistingAppInstance = useMemo(() => {
+    return environments
+      .map((env) => {
+        const existingAppInstance = appInstances.find(
+          (inst) => inst.name === env.name
+        );
+
+        return {
+          ...env,
+          existingAppInstance,
+        };
+      })
+      .filter((ev) => ev.name.includes(searchValue));
+  }, [environments, appInstances, searchValue]);
+
+  if (status === "loading") {
+    return <Loading offset="-150px" />;
+  }
+
+  if (appInstances.length === 0) {
+    return (
+      <DashboardPlaceholder>
+        <Text size={16}>No apps have been deployed yet.</Text>
+        <Spacer y={0.5} />
+        <Text color={"helper"}>Get started by creating a new app.</Text>
+        <Spacer y={1} />
+        <Button
+          alt
+          height="35px"
+          onClick={() => {
+            history.push("/apps/new/app");
+          }}
+        >
+          Create App
+        </Button>
+      </DashboardPlaceholder>
     );
   }
 
   return (
-    <List>
-      {apps.map((a) => (
-        <ConfigurableAppRow
-          key={a.source.id}
-          setEditingApp={() => {
-            queryParams.set("target", a.app_revision.deployment_target.id);
-            queryParams.set("app_name", a.source.name);
+    <>
+      <Container row spaced>
+        <SearchBar
+          value={searchValue}
+          setValue={(x) => {
+            setSearchValue(x);
+          }}
+          placeholder="Search environment templates . . ."
+          width="100%"
+        />
+        <Spacer inline x={1} />
+        <Button
+          onClick={() => {
             history.push({
               pathname: "/preview-environments/configure",
-              search: queryParams.toString(),
             });
           }}
-          app={a}
-        />
-      ))}
-    </List>
+          height="30px"
+          width="140px"
+        >
+          <Container row>
+            <Image src={add} size={12} />
+            <Spacer inline x={0.5} />
+            <Text>New Template</Text>
+          </Container>
+        </Button>
+      </Container>
+      <Spacer y={1} />
+      <List>
+        {envsWithExistingAppInstance.map((ev) => (
+          <ConfigurableAppRow
+            key={ev.name}
+            setEditingApp={() => {
+              queryParams.set("app_name", ev.name);
+              if (ev.existingAppInstance?.deployment_target.id) {
+                queryParams.set(
+                  "target",
+                  ev.existingAppInstance.deployment_target.id.toString()
+                );
+              }
+
+              history.push({
+                pathname: "/preview-environments/configure",
+                search: queryParams.toString(),
+              });
+            }}
+            env={ev}
+          />
+        ))}
+      </List>
+    </>
   );
 };
 
-const CentralContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: left;
-  align-items: left;
-`;
-
 const List = styled.div`
   overflow: hidden;
 `;

+ 69 - 61
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppRow.tsx

@@ -1,88 +1,96 @@
-import React, { useMemo } from "react";
-import { PorterApp } from "@porter-dev/api-contracts";
+import React from "react";
+import pluralize from "pluralize";
 import styled from "styled-components";
 
 import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { AppIcon, AppSource } from "main/home/app-dashboard/apps/AppMeta";
-import { type AppRevisionWithSource } from "main/home/app-dashboard/apps/types";
+import { AppSource } from "main/home/app-dashboard/apps/AppMeta";
+import { type Environment } from "lib/environments/types";
 
-import settings from "assets/settings.svg";
+import addOns from "assets/add-ons.svg";
+import database from "assets/database.svg";
 
 type Props = {
-  app: AppRevisionWithSource;
+  env: Environment;
   setEditingApp: () => void;
 };
 
-export const ConfigurableAppRow: React.FC<Props> = ({ app, setEditingApp }) => {
-  const proto = useMemo(() => {
-    return PorterApp.fromJsonString(atob(app.app_revision.b64_app_proto), {
-      ignoreUnknownFields: true,
-    });
-  }, [app.app_revision.b64_app_proto]);
+export const ConfigurableAppRow: React.FC<Props> = ({ env, setEditingApp }) => {
+  const firstApp = env.apps?.[0];
+
+  if (!firstApp) {
+    return null;
+  }
 
   return (
-    <Row>
-      <div>
-        <Container row>
-          <Spacer inline width="1px" />
-          <AppIcon buildpacks={proto.build?.buildpacks ?? []} />
-          <Spacer inline width="12px" />
-          <Text size={14}>{proto.name}</Text>
-          <Spacer inline x={1} />
-        </Container>
-        <Spacer height="15px" />
+    <Row
+      onClick={() => {
+        setEditingApp();
+      }}
+    >
+      <Container row>
+        <Spacer inline width="1px" />
+        <Icon src={addOns} height="18px" />
+        <Spacer inline width="12px" />
+        <Text size={14}>{env.name}</Text>
+        <Spacer inline x={1} />
+      </Container>
+      <Spacer height="15px" />
+      <Container row>
+        <AppSource
+          source={{
+            from: "app_contract",
+            details: firstApp,
+          }}
+        />
+        <Spacer inline x={1} />
         <Container row>
-          <AppSource source={app.source} />
-          <Spacer inline x={1} />
+          <DBIcon opacity="0.6" src={database} />
+          <Text truncate={true} size={13} color="#ffffff44">
+            {`${env.addons.length > 0 ? env.addons.length : "No"} ${pluralize(
+              "datastore",
+              env.addons.length
+            )} included`}
+          </Text>
         </Container>
-      </div>
-      <div
-        style={{
-          display: "flex",
-          alignItems: "center",
-        }}
-      >
-        <SettingsButton
-          onClick={() => {
-            setEditingApp();
-          }}
-        >
-          <img src={settings} />
-          <Spacer inline x={0.5} />
-          Update Previews
-        </SettingsButton>
-      </div>
+      </Container>
     </Row>
   );
 };
 
-const SettingsButton = styled.button`
-  background: ${(props) => props.theme.fg};
-  padding: 8px 12px;
-  border-radius: 5px;
-  border: 1px solid #494b4f;
+export const Row = styled.div`
   cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  :hover {
-    filter: brightness(120%);
-  }
-`;
-
-const Row = styled.div<{ isAtBottom?: boolean }>`
   padding: 15px;
-  border-bottom: ${(props) =>
-    props.isAtBottom ? "none" : "1px solid #494b4f"};
-  background: ${({ theme }) => theme.fg};
+  border-bottom: 1px solid #494b4f;
+  background: ${(props) => props.theme.clickable.bg};
   position: relative;
   border: 1px solid #494b4f;
   border-radius: 5px;
   margin-bottom: 15px;
+
+  transition: all 0.2s;
+
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
   animation: fadeIn 0.3s 0s;
-  display: flex;
-  justify-content: space-between;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const DBIcon = styled.img<{ opacity?: string; height?: string }>`
+  margin-left: 2px;
+  height: ${(props) => props.height || "14px"};
+  opacity: ${(props) => props.opacity || 1};
+  filter: grayscale(100%);
+  margin-right: 10px;
 `;

+ 5 - 8
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/DeleteEnvModal.tsx

@@ -1,11 +1,10 @@
+import React from "react";
+
 import Button from "components/porter/Button";
 import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 
-import React from "react";
-import styled from "styled-components";
-
 type Props = {
   closeModal: () => void;
   deleteEnv: () => Promise<void>;
@@ -32,7 +31,9 @@ const DeleteEnvModal: React.FC<Props> = ({
       </Text>
       <Spacer y={1} />
       <Button
-        onClick={() => deleteEnv()}
+        onClick={() => {
+          void deleteEnv();
+        }}
         color="#b91133"
         status={loading ? "loading" : ""}
         loadingText="Deleting..."
@@ -45,7 +46,3 @@ const DeleteEnvModal: React.FC<Props> = ({
 };
 
 export default DeleteEnvModal;
-
-const Code = styled.span`
-  font-family: monospace;
-`;

+ 306 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/EnvTemplateContextProvider.tsx

@@ -0,0 +1,306 @@
+import React, {
+  createContext,
+  useCallback,
+  useContext,
+  useMemo,
+  useState,
+} from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { type PorterApp } from "@porter-dev/api-contracts";
+import axios from "axios";
+import _ from "lodash";
+import { FormProvider, useForm } from "react-hook-form";
+import { match } from "ts-pattern";
+import { z } from "zod";
+
+import { Error as ErrorComponent } from "components/porter/Error";
+import { clientAddonToProto, clientAddonValidator } from "lib/addons";
+import {
+  APP_CREATE_FORM_DEFAULTS,
+  basePorterAppFormValidator,
+  clientAppToProto,
+} from "lib/porter-apps";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useDeploymentTarget } from "shared/DeploymentTargetContext";
+
+type EnvTemplateContextType = {
+  showGHAModal: boolean;
+  setShowGHAModal: React.Dispatch<React.SetStateAction<boolean>>;
+  createError: string;
+  setCreateError: React.Dispatch<string>;
+  validatedAppProto: PorterApp | null;
+  setValidatedAppProto: React.Dispatch<PorterApp | null>;
+  encodedAddons: EncodedAddonWithEnv[];
+  setEncodedAddons: React.Dispatch<EncodedAddonWithEnv[]>;
+  variables: Record<string, string>;
+  secrets: Record<string, string>;
+  setFinalizedAppEnv: React.Dispatch<{
+    variables: Record<string, string>;
+    secrets: Record<string, string>;
+  }>;
+  buttonStatus: "" | "loading" | JSX.Element | "success";
+  savePreviewConfig: (args: { repo?: RepoOverrides }) => Promise<void>;
+};
+
+export const EnvTemplateContext = createContext<EnvTemplateContextType | null>(
+  null
+);
+
+export const useEnvTemplate = (): EnvTemplateContextType => {
+  const ctx = useContext(EnvTemplateContext);
+  if (!ctx) {
+    throw new Error(
+      "useEnvTemplateContext must be used within a EnvTemplateContextProvider"
+    );
+  }
+  return ctx;
+};
+
+export const appTemplateClientValidator = basePorterAppFormValidator.extend({
+  addons: z.array(clientAddonValidator).default([]),
+});
+export type AppTemplateFormData = z.infer<typeof appTemplateClientValidator>;
+
+export type EncodedAddonWithEnv = {
+  base64_addon: string;
+  variables: Record<string, string>;
+  secrets: Record<string, string>;
+};
+
+export type RepoOverrides = {
+  id: number;
+  fullName: string;
+};
+
+export const EnvTemplateContextProvider: React.FC<{
+  children: React.ReactNode;
+  shouldShowGHAModal?: boolean;
+}> = ({ children, shouldShowGHAModal }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const { currentDeploymentTarget } = useDeploymentTarget();
+
+  const [showGHAModal, setShowGHAModal] = useState(false);
+  const [createError, setCreateError] = useState<string>("");
+  const [validatedAppProto, setValidatedAppProto] = useState<PorterApp | null>(
+    null
+  );
+  const [encodedAddons, setEncodedAddons] = useState<EncodedAddonWithEnv[]>([]);
+  const [{ variables, secrets }, setFinalizedAppEnv] = useState<{
+    variables: Record<string, string>;
+    secrets: Record<string, string>;
+  }>({
+    variables: {},
+    secrets: {},
+  });
+
+  const envTemplateFormMethods = useForm<AppTemplateFormData>({
+    reValidateMode: "onSubmit",
+    resolver: zodResolver(appTemplateClientValidator),
+    defaultValues: {
+      ...APP_CREATE_FORM_DEFAULTS,
+      addons: [],
+    },
+  });
+
+  const {
+    handleSubmit,
+    formState: { errors, isSubmitting, isSubmitSuccessful },
+  } = envTemplateFormMethods;
+
+  const errorMessagesDeep = useMemo(() => {
+    return Object.values(_.mapValues(errors, (error) => error?.message));
+  }, [errors]);
+
+  const buttonStatus = useMemo(() => {
+    if (isSubmitting) {
+      return "loading";
+    }
+
+    if (errorMessagesDeep.length > 0) {
+      return (
+        <ErrorComponent
+          message={`App update failed. ${errorMessagesDeep[0]}`}
+        />
+      );
+    }
+
+    if (isSubmitSuccessful) {
+      return "success";
+    }
+
+    return "";
+  }, [isSubmitting, errorMessagesDeep]);
+
+  const onSubmit = handleSubmit(async (data) => {
+    setCreateError("");
+
+    const proto = clientAppToProto(data);
+    setValidatedAppProto(proto);
+
+    const addons = data.addons.map((addon) => {
+      const variables = match(addon.config)
+        .returnType<Record<string, string>>()
+        .with({ type: "postgres" }, (conf) => ({
+          POSTGRESQL_USERNAME: conf.username,
+        }))
+        .with({ type: "redis" }, (conf) => ({
+          REDIS_PASSWORD: conf.password,
+        }))
+        .otherwise(() => ({}));
+      const secrets = match(addon.config)
+        .returnType<Record<string, string>>()
+        .with({ type: "postgres" }, (conf) => ({
+          POSTGRESQL_PASSWORD: conf.password,
+        }))
+        .with({ type: "redis" }, (conf) => ({
+          REDIS_PASSWORD: conf.password,
+        }))
+        .otherwise(() => ({}));
+
+      const proto = clientAddonToProto(addon);
+
+      return {
+        base64_addon: btoa(proto.toJsonString()),
+        variables,
+        secrets,
+      };
+    });
+    setEncodedAddons(addons);
+
+    const { env } = data.app;
+    const variables = env
+      .filter((e) => !e.hidden && !e.deleted)
+      .reduce((acc: Record<string, string>, item) => {
+        acc[item.key] = item.value;
+        return acc;
+      }, {});
+    const secrets = env
+      .filter((e) => !e.deleted)
+      .reduce((acc: Record<string, string>, item) => {
+        if (item.hidden) {
+          acc[item.key] = item.value;
+        }
+        return acc;
+      }, {});
+    setFinalizedAppEnv({ variables, secrets });
+
+    if (shouldShowGHAModal) {
+      setShowGHAModal(true);
+      return;
+    }
+
+    await createTemplateAndWorkflow({
+      app: proto,
+      variables,
+      secrets,
+      addons,
+    });
+  });
+
+  const createTemplateAndWorkflow = useCallback(
+    async ({
+      app,
+      variables,
+      secrets,
+      addons = [],
+      repo,
+    }: {
+      app: PorterApp | null;
+      variables: Record<string, string>;
+      secrets: Record<string, string>;
+      addons?: EncodedAddonWithEnv[];
+      repo?: RepoOverrides;
+    }) => {
+      try {
+        if (
+          !app ||
+          !currentDeploymentTarget ||
+          !currentCluster ||
+          !currentProject
+        ) {
+          return false;
+        }
+
+        await api.createAppTemplate(
+          "<token>",
+          {
+            b64_app_proto: btoa(app.toJsonString()),
+            variables,
+            secrets,
+            base_deployment_target_id: currentDeploymentTarget.id,
+            addons,
+            ...(repo && {
+              git_overrides: {
+                git_repo_id: repo.id,
+                git_repo_name: repo.fullName,
+              },
+            }),
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            porter_app_name: app.name,
+          }
+        );
+
+        return true;
+      } catch (err) {
+        if (axios.isAxiosError(err) && err.response?.data?.error) {
+          setCreateError(err.response?.data?.error);
+          return false;
+        }
+
+        setCreateError(
+          "An error occurred while creating the CI workflow. Please try again."
+        );
+        return false;
+      }
+    },
+    [currentDeploymentTarget?.id, currentCluster?.id, currentProject?.id]
+  );
+
+  const savePreviewConfig = useCallback(
+    async ({ repo }: { repo?: RepoOverrides }) => {
+      await createTemplateAndWorkflow({
+        app: validatedAppProto,
+        variables,
+        secrets,
+        addons: encodedAddons,
+        repo,
+      });
+    },
+    [
+      createTemplateAndWorkflow,
+      encodedAddons,
+      secrets,
+      validatedAppProto,
+      variables,
+    ]
+  );
+
+  return (
+    <EnvTemplateContext.Provider
+      value={{
+        showGHAModal,
+        setShowGHAModal,
+        createError,
+        setCreateError,
+        validatedAppProto,
+        setValidatedAppProto,
+        encodedAddons,
+        setEncodedAddons,
+        variables,
+        secrets,
+        setFinalizedAppEnv,
+        buttonStatus,
+        savePreviewConfig,
+      }}
+    >
+      <FormProvider {...envTemplateFormMethods}>
+        <form onSubmit={onSubmit}>{children}</form>
+      </FormProvider>
+    </EnvTemplateContext.Provider>
+  );
+};

+ 33 - 50
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx

@@ -11,7 +11,7 @@ import { match } from "ts-pattern";
 
 import Button from "components/porter/Button";
 import Container from "components/porter/Container";
-import Fieldset from "components/porter/Fieldset";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
 import Icon from "components/porter/Icon";
 import SearchBar from "components/porter/SearchBar";
 import Spacer from "components/porter/Spacer";
@@ -22,7 +22,6 @@ import type { DeploymentTarget } from "lib/hooks/useDeploymentTarget";
 import { search } from "shared/search";
 import { readableDate } from "shared/string_utils";
 import calendar from "assets/calendar-number.svg";
-import notFound from "assets/not-found.png";
 import pull_request from "assets/pull_request_icon.svg";
 import healthy from "assets/status-healthy.png";
 import time from "assets/time.png";
@@ -58,40 +57,23 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
 
   if (deploymentTargets.length === 0) {
     return (
-      <Fieldset>
-        <CentralContainer>
-          <Text size={16}>No preview environments have been deployed yet.</Text>
-          <Spacer y={1} />
-
-          <Text color={"helper"}>
-            Get started by enabling preview envs for your apps.
-          </Text>
-          <Spacer y={1} />
-          <Button
-            onClick={() => {
-              setTab("config");
-            }}
-          >
-            Configure Preview Environments
-          </Button>
-        </CentralContainer>
-      </Fieldset>
-    );
-  }
-
-  if (filteredEnvs.length === 0) {
-    let copy =
-      "No preview environments exist. To get started with preview environments, enable them in the Settings tab of an existing application.";
-    if (searchValue !== "") {
-      copy = "No matching environments were found.";
-    }
-    return (
-      <Fieldset>
-        <Container row>
-          <PlaceholderIcon src={notFound} />
-          <Text color="helper">{copy}</Text>
-        </Container>
-      </Fieldset>
+      <DashboardPlaceholder>
+        <Text size={16}>No preview environments have been deployed yet.</Text>
+        <Spacer y={0.5} />
+        <Text color={"helper"}>
+          Get started by enabling preview envs for your apps.
+        </Text>
+        <Spacer y={1} />
+        <Button
+          alt
+          height="35px"
+          onClick={() => {
+            setTab("config");
+          }}
+        >
+          Set up
+        </Button>
+      </DashboardPlaceholder>
     );
   }
 
@@ -106,7 +88,7 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
           placeholder="Search environments . . ."
           width="100%"
         />
-        <Spacer inline x={2} />
+        <Spacer inline x={1} />
         <Toggle
           items={[
             { label: <ToggleIcon src={calendar} />, value: "calendar" },
@@ -157,12 +139,6 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
 
 export default PreviewEnvGrid;
 
-const PlaceholderIcon = styled.img`
-  height: 13px;
-  margin-right: 12px;
-  opacity: 0.65;
-`;
-
 const List = styled.div`
   overflow: hidden;
 `;
@@ -177,7 +153,21 @@ const Row = styled.div<{ isAtBottom?: boolean }>`
   border: 1px solid #494b4f;
   border-radius: 5px;
   margin-bottom: 15px;
+  transition: all 0.2s;
+
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
   animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
 `;
 
 const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
@@ -193,10 +183,3 @@ const ToggleIcon = styled.img`
   margin: 0 5px;
   min-width: 12px;
 `;
-
-const CentralContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: left;
-  align-items: left;
-`;

+ 14 - 9
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx

@@ -55,7 +55,9 @@ const PreviewEnvs: React.FC = () => {
     if (currentProject?.sandbox_enabled) {
       return (
         <DashboardPlaceholder>
-          <Text size={16}>Preview apps are coming soon to the Porter Cloud</Text>
+          <Text size={16}>
+            Preview environments are coming soon to the Porter Cloud
+          </Text>
           <Spacer y={0.5} />
           <Text color={"helper"}>
             You can also eject to your own cloud account to start using preview
@@ -64,7 +66,7 @@ const PreviewEnvs: React.FC = () => {
           <Spacer y={1} />
           <PorterLink to="https://docs.porter.run/other/eject">
             <Button alt height="35px">
-             Eject to AWS, Azure, or GCP
+              Eject to AWS, Azure, or GCP
             </Button>
           </PorterLink>
         </DashboardPlaceholder>
@@ -74,15 +76,18 @@ const PreviewEnvs: React.FC = () => {
     if (!currentProject?.preview_envs_enabled) {
       return (
         <DashboardPlaceholder>
-          <Text size={16}>Preview apps are not enabled for this project</Text>
+          <Text size={16}>
+            Preview environments are not enabled for this project
+          </Text>
           <Spacer y={0.5} />
           <Text color={"helper"}>
-            Reach out to the Porter team to enable preview apps on your project.
+            Reach out to the Porter team to enable preview environments on your
+            project.
           </Text>
           <Spacer y={1} />
           <ShowIntercomButton
             alt
-            message="I would like to enable preview apps on my project"
+            message="I would like to enable preview environments on my project"
             height="35px"
           >
             Request to enable
@@ -96,8 +101,8 @@ const PreviewEnvs: React.FC = () => {
         <TabSelector
           noBuffer
           options={[
-            { label: "Existing Previews", value: "environments" },
-            { label: "Preview Templates", value: "config" },
+            { label: "Current Previews", value: "environments" },
+            { label: "Setup", value: "config" },
           ]}
           currentTab={tab}
           setCurrentTab={(tab: string) => {
@@ -120,13 +125,13 @@ const PreviewEnvs: React.FC = () => {
         image={prGrad}
         title={
           <Container row>
-            Preview apps
+            Preview environments
             <Spacer inline x={1} />
             <Badge>Beta</Badge>
           </Container>
         }
         capitalize={false}
-        description="Preview apps are created for each pull request. They are automatically deleted when the pull request is closed."
+        description="Preview environments are created for each pull request. They are automatically deleted when the pull request is closed."
         disableLineBreak
       />
       {renderContents()}

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/Addons.tsx

@@ -24,9 +24,9 @@ export const Addons: React.FC<Props> = ({ buttonStatus }) => {
       <Text size={16}>Add-ons</Text>
       <Spacer y={0.5} />
       <Text color="helper">
-        Include any add-ons you would like to be created with your preview app.
-        These are also ephemeral and will only be accessible for the lifetime of
-        the preview app.
+        Include any add-ons you would like to be created with your preview
+        environment. These are also ephemeral and will only be accessible for
+        the lifetime of the preview environment.
       </Text>
       <Spacer y={0.5} />
       <AddonsList />

+ 176 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/AppSelector.tsx

@@ -0,0 +1,176 @@
+import React, {
+  useContext,
+  useMemo,
+  useState,
+  type Dispatch,
+  type SetStateAction,
+} from "react";
+import { PorterApp } from "@porter-dev/api-contracts";
+import styled from "styled-components";
+
+import SearchBar from "components/porter/SearchBar";
+import { AppIcon } from "main/home/app-dashboard/apps/AppMeta";
+import { type AppInstance } from "main/home/app-dashboard/apps/types";
+import { useLatestAppRevisions } from "lib/hooks/useLatestAppRevisions";
+
+import { Context } from "shared/Context";
+import { search } from "shared/search";
+
+type Props = {
+  selectedApp: AppInstance | null;
+  setSelectedApp: Dispatch<SetStateAction<AppInstance | null>>;
+};
+
+export const AppSelector: React.FC<Props> = ({
+  selectedApp,
+  setSelectedApp,
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [searchValue, setSearchValue] = useState("");
+
+  const { revisions: apps } = useLatestAppRevisions({
+    projectId: currentProject?.id ?? 0,
+    clusterId: currentCluster?.id ?? 0,
+  });
+
+  const filteredApps = useMemo(() => {
+    const withProto = apps.map((app) => {
+      return {
+        ...app,
+        app_revision: {
+          ...app.app_revision,
+          proto: PorterApp.fromJsonString(
+            atob(app.app_revision.b64_app_proto),
+            {
+              ignoreUnknownFields: true,
+            }
+          ),
+        },
+      };
+    });
+
+    return search(withProto, searchValue, {
+      keys: ["app_revision.proto.name"],
+      isCaseSensitive: false,
+    });
+  }, [apps, searchValue]);
+
+  return (
+    <ExpandedWrapper>
+      <div style={{ display: "flex", marginBottom: "10px" }}>
+        <SearchBar
+          value={searchValue}
+          setValue={setSearchValue}
+          placeholder={"Search apps . . ."}
+          width="100%"
+        />
+      </div>
+      <ListContainer>
+        <ListWrapper>
+          {filteredApps.map((app, i) => {
+            return (
+              <AppItem
+                key={i}
+                onClick={() => {
+                  setSelectedApp({
+                    id: app.app_revision.app_instance_id,
+                    name: app.source.name,
+                    deployment_target: {
+                      id: app.app_revision.deployment_target.id,
+                      name: app.app_revision.deployment_target.name,
+                    },
+                  });
+                }}
+                isSelected={selectedApp?.name === app.app_revision.proto.name}
+              >
+                <AppIcon
+                  buildpacks={app.app_revision.proto.build?.buildpacks ?? []}
+                />
+                {app.source.name}
+              </AppItem>
+            );
+          })}
+        </ListWrapper>
+      </ListContainer>
+    </ExpandedWrapper>
+  );
+};
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  max-height: 275px;
+`;
+
+const ListContainer = styled.div`
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  overflow-y: auto;
+`;
+
+const ListWrapper = styled.div`
+  width: 100%;
+  border-radius: 3px;
+  border: 0px solid #ffffff44;
+  max-height: 221px;
+  top: 40px;
+
+  > i {
+    font-size: 18px;
+    display: block;
+    position: absolute;
+    left: 10px;
+    top: 10px;
+  }
+`;
+
+const AppItem = styled.div<{
+  isSelected?: boolean;
+  readOnly?: boolean;
+  disabled?: boolean;
+  lastItem?: boolean;
+}>`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid
+    ${(props) => (props.lastItem ? "#00000000" : "#606166")};
+  color: ${(props) => (props.disabled ? "#ffffff88" : "#ffffff")};
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: ${(props) =>
+    props.readOnly || props.disabled ? "default" : "pointer"};
+  pointer-events: ${(props) =>
+    props.readOnly || props.disabled ? "none" : "auto"};
+
+  ${(props) => {
+    if (props.disabled) {
+      return "";
+    }
+
+    if (props.isSelected) {
+      return `background: #ffffff22;`;
+    }
+
+    return `background: #ffffff11;`;
+  }}
+
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img,
+  i {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 20px;
+  }
+`;

+ 54 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/ConsolidatedServices.tsx

@@ -0,0 +1,54 @@
+import React from "react";
+import { useFormContext } from "react-hook-form";
+
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import ServiceList from "main/home/app-dashboard/validate-apply/services-settings/ServiceList";
+
+import { useDeploymentTarget } from "shared/DeploymentTargetContext";
+
+import { type AppTemplateFormData } from "../EnvTemplateContextProvider";
+
+export const ConsolidatedServices: React.FC = () => {
+  const { watch } = useFormContext<AppTemplateFormData>();
+  const { currentDeploymentTarget } = useDeploymentTarget();
+
+  const initialDeploy = watch("app.initialDeploy");
+  const predeploy = watch("app.predeploy");
+  const name = watch("app.name");
+
+  return (
+    <>
+      <Text size={16}>Initial deploy job</Text>
+      <Spacer y={0.5} />
+      <ServiceList
+        addNewText={"Add a new initial deploy job"}
+        existingServiceNames={initialDeploy ? ["initdeploy"] : []}
+        lifecycleJobType="initdeploy"
+        fieldArrayName={"app.initialDeploy"}
+      />
+      <Spacer y={0.5} />
+      <Text size={16}>Pre-deploy job</Text>
+      <Spacer y={0.5} />
+      <ServiceList
+        addNewText={"Add a new pre-deploy job"}
+        existingServiceNames={predeploy ? ["pre-deploy"] : []}
+        lifecycleJobType="predeploy"
+        fieldArrayName={"app.predeploy"}
+      />
+      <Spacer y={0.5} />
+      <Text size={16}>Application services</Text>
+      <Spacer y={0.5} />
+      <ServiceList
+        addNewText={"Add a new service"}
+        fieldArrayName={"app.services"}
+        internalNetworkingDetails={{
+          namespace: currentDeploymentTarget?.namespace || "default",
+          appName: name.value,
+        }}
+        allowAddServices={false}
+      />
+      <Spacer y={0.75} />
+    </>
+  );
+};

+ 219 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/CreateTemplate.tsx

@@ -0,0 +1,219 @@
+import React, { useEffect, useState } from "react";
+import _ from "lodash";
+import { useFormContext } from "react-hook-form";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+
+import Back from "components/porter/Back";
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import { LatestRevisionProvider } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import { type AppInstance } from "main/home/app-dashboard/apps/types";
+import EnvSettings from "main/home/app-dashboard/validate-apply/app-settings/EnvSettings";
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import { AddonsList } from "main/home/managed-addons/AddonsList";
+import { usePorterYaml } from "lib/hooks/usePorterYaml";
+
+import { valueExists } from "shared/util";
+import addOns from "assets/add-ons.svg";
+
+import {
+  useEnvTemplate,
+  type AppTemplateFormData,
+} from "../EnvTemplateContextProvider";
+import { AppSelector } from "./AppSelector";
+import { ConsolidatedServices } from "./ConsolidatedServices";
+import { PreviewGHAModal } from "./PreviewGHAModal";
+import { RevisionLoader } from "./RevisionLoader";
+
+export const CreateTemplate: React.FC = () => {
+  const history = useHistory();
+
+  const [step, setStep] = useState(0);
+  const [selectedApp, setSelectedApp] = useState<AppInstance | null>(null);
+  const [detectedServices, setDetectedServices] = useState<{
+    detected: boolean;
+    count: number;
+  }>({ detected: false, count: 0 });
+
+  const {
+    showGHAModal,
+    setShowGHAModal,
+    createError,
+    buttonStatus,
+    savePreviewConfig,
+  } = useEnvTemplate();
+
+  const {
+    formState: { isSubmitting },
+    setValue,
+    watch,
+  } = useFormContext<AppTemplateFormData>();
+
+  const source = watch("source");
+
+  const { detectedServices: servicesFromYaml, detectedName } = usePorterYaml({
+    source: source.type === "github" ? source : null,
+    appName: "",
+  });
+
+  useEffect(() => {
+    if (servicesFromYaml && !detectedServices.detected) {
+      const { services, predeploy, build: detectedBuild } = servicesFromYaml;
+      setValue("app.services", services);
+      setValue("app.predeploy", [predeploy].filter(valueExists));
+
+      if (detectedBuild) {
+        setValue("app.build", detectedBuild);
+      }
+      setDetectedServices({
+        detected: true,
+        count: services.length,
+      });
+    }
+
+    if (!servicesFromYaml && detectedServices.detected) {
+      setValue("app.services", []);
+      setValue("app.predeploy", []);
+      setDetectedServices({
+        detected: false,
+        count: 0,
+      });
+    }
+  }, [servicesFromYaml, detectedName, detectedServices.detected]);
+
+  useEffect(() => {
+    if (selectedApp?.deployment_target.id) {
+      const queryParams = new URLSearchParams(location.search);
+      queryParams.set("target", selectedApp.deployment_target.id.toString());
+      history.push({
+        search: queryParams.toString(),
+      });
+    }
+  }, [selectedApp]);
+
+  useEffect(() => {
+    if (selectedApp) {
+      setStep(3);
+    }
+  }, [selectedApp?.id]);
+
+  return (
+    <>
+      <Back to="/preview-environments" />
+      <DashboardHeader
+        prefix={<Icon src={addOns} />}
+        title="Create a new preview template"
+        capitalize={false}
+        disableLineBreak
+      />
+      <DarkMatter />
+      <div>
+        <VerticalSteps
+          currentStep={step}
+          steps={[
+            <>
+              <Text size={16}>Choose an existing app</Text>
+              <Spacer y={0.5} />
+              <AppSelector
+                selectedApp={selectedApp}
+                setSelectedApp={setSelectedApp}
+              />
+              <Spacer y={0.5} />
+            </>,
+            <>
+              <Text size={16}>Datastore Addons</Text>
+              <Spacer y={0.5} />
+              <Text color="helper">
+                Ephemeral datastores will be created for each preview
+                environment
+              </Text>
+              <Spacer y={0.5} />
+              <AddonsList />
+              <Spacer y={0.5} />
+            </>,
+            !selectedApp?.name ? (
+              <>
+                <Text size={16}>Service overrides</Text>
+                <Spacer y={0.5} />
+                <ConsolidatedServices />
+                <Text size={16}>Env variable overrides</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Change environment variables to test keys or values suitable
+                  for previews
+                </Text>
+                <EnvSettings baseEnvGroups={[]} />
+              </>
+            ) : (
+              <LatestRevisionProvider
+                key={selectedApp?.id}
+                appName={selectedApp?.name}
+              >
+                <>
+                  <RevisionLoader>
+                    <Text size={16}>Service overrides</Text>
+                    <Spacer y={0.5} />
+                    <ConsolidatedServices />
+                    <Text size={16}>Env variable overrides</Text>
+                    <Spacer y={0.5} />
+                    <Text color="helper">
+                      Change environment variables to test keys or values
+                      suitable for previews
+                    </Text>
+                    <EnvSettings baseEnvGroups={[]} />
+                    {showGHAModal && (
+                      <PreviewGHAModal
+                        onClose={() => {
+                          setShowGHAModal(false);
+                        }}
+                        savePreviewConfig={savePreviewConfig}
+                        error={createError}
+                      />
+                    )}
+                  </RevisionLoader>
+                </>
+              </LatestRevisionProvider>
+            ),
+            <>
+              <Button
+                type="submit"
+                status={buttonStatus}
+                loadingText={"Creating..."}
+                width={"120px"}
+                disabled={isSubmitting}
+              >
+                Create
+              </Button>
+            </>,
+          ].filter((x) => x)}
+        />
+        <Spacer y={3} />
+      </div>
+    </>
+  );
+};
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -5px;
+`;
+
+const Icon = styled.img`
+  margin-right: 15px;
+  height: 28px;
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;

+ 29 - 260
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer.tsx

@@ -1,28 +1,19 @@
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { type PorterApp } from "@porter-dev/api-contracts";
-import axios from "axios";
+import React, { useEffect, useMemo, useState } from "react";
 import _ from "lodash";
-import { FormProvider, useForm } from "react-hook-form";
-import { useHistory } from "react-router";
+import { useFormContext } from "react-hook-form";
 import { match } from "ts-pattern";
-import { z } from "zod";
 
-import Error from "components/porter/Error";
 import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
 import Environment from "main/home/app-dashboard/app-view/tabs/Environment";
-import {
-  clientAddonFromProto,
-  clientAddonToProto,
-  clientAddonValidator,
-} from "lib/addons";
+import { clientAddonFromProto } from "lib/addons";
 import { useAppWithPreviewOverrides } from "lib/hooks/useAppWithPreviewOverrides";
-import { basePorterAppFormValidator, clientAppToProto } from "lib/porter-apps";
-
-import api from "shared/api";
 
+import {
+  useEnvTemplate,
+  type AppTemplateFormData,
+} from "../EnvTemplateContextProvider";
 import { type ExistingTemplateWithEnv } from "../types";
 import { Addons } from "./Addons";
 import { PreviewGHAModal } from "./PreviewGHAModal";
@@ -42,52 +33,22 @@ const previewEnvSettingsTabs = [
 
 type PreviewEnvSettingsTab = (typeof previewEnvSettingsTabs)[number];
 
-const appTemplateClientValidator = basePorterAppFormValidator.extend({
-  addons: z.array(clientAddonValidator).default([]),
-});
-export type AppTemplateFormData = z.infer<typeof appTemplateClientValidator>;
-
-type EncodedAddonWithEnv = {
-  base64_addon: string;
-  variables: Record<string, string>;
-  secrets: Record<string, string>;
-};
-
-export type RepoOverrides = {
-  id: number;
-  fullName: string;
-};
-
 export const PreviewAppDataContainer: React.FC<Props> = ({
   existingTemplate,
 }) => {
-  const history = useHistory();
-
   const [tab, setTab] = useState<PreviewEnvSettingsTab>("services");
-  const [validatedAppProto, setValidatedAppProto] = useState<PorterApp | null>(
-    null
-  );
-  const [createError, setCreateError] = useState("");
-  const [showGHAModal, setShowGHAModal] = useState(false);
-  const [{ variables, secrets }, setFinalizedAppEnv] = useState<{
-    variables: Record<string, string>;
-    secrets: Record<string, string>;
-  }>({
-    variables: {},
-    secrets: {},
-  });
-  const [encodedAddons, setEncodedAddons] = useState<EncodedAddonWithEnv[]>([]);
-
   const {
-    porterApp,
-    appEnv,
-    latestProto,
-    servicesFromYaml,
-    clusterId,
-    projectId,
-    deploymentTarget,
-    latestSource,
-  } = useLatestRevision();
+    showGHAModal,
+    setShowGHAModal,
+    createError,
+    buttonStatus,
+    savePreviewConfig,
+  } = useEnvTemplate();
+
+  const { appEnv, latestProto, servicesFromYaml, latestSource } =
+    useLatestRevision();
+
+  const { reset } = useFormContext<AppTemplateFormData>();
 
   const withPreviewOverrides = useAppWithPreviewOverrides({
     latestApp: latestProto,
@@ -113,182 +74,6 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
     return existingAddons;
   }, [existingTemplate?.addons]);
 
-  const porterAppFormMethods = useForm<AppTemplateFormData>({
-    reValidateMode: "onSubmit",
-    resolver: zodResolver(appTemplateClientValidator),
-    defaultValues: {
-      app: withPreviewOverrides,
-      source: latestSource,
-      deletions: {
-        serviceNames: [],
-        envGroupNames: [],
-        predeploy: [],
-        initialDeploy: [],
-      },
-      addons: [],
-    },
-  });
-
-  const {
-    reset,
-    handleSubmit,
-    formState: { errors, isSubmitting, isSubmitSuccessful },
-  } = porterAppFormMethods;
-
-  const errorMessagesDeep = useMemo(() => {
-    return Object.values(_.mapValues(errors, (error) => error?.message));
-  }, [errors]);
-
-  const buttonStatus = useMemo(() => {
-    if (isSubmitting) {
-      return "loading";
-    }
-
-    if (errorMessagesDeep.length > 0) {
-      return <Error message={`App update failed. ${errorMessagesDeep[0]}`} />;
-    }
-
-    if (isSubmitSuccessful) {
-      return "success";
-    }
-
-    return "";
-  }, [isSubmitting, errorMessagesDeep]);
-
-  const onSubmit = handleSubmit(async (data) => {
-    try {
-      setCreateError("");
-
-      const proto = clientAppToProto(data);
-      setValidatedAppProto(proto);
-
-      const addons = data.addons.map((addon) => {
-        const variables = match(addon.config)
-          .returnType<Record<string, string>>()
-          .with({ type: "postgres" }, (conf) => ({
-            POSTGRESQL_USERNAME: conf.username,
-          }))
-          .with({ type: "redis" }, (conf) => ({
-            REDIS_PASSWORD: conf.password,
-          }))
-          .otherwise(() => ({}));
-        const secrets = match(addon.config)
-          .returnType<Record<string, string>>()
-          .with({ type: "postgres" }, (conf) => ({
-            POSTGRESQL_PASSWORD: conf.password,
-          }))
-          .with({ type: "redis" }, (conf) => ({
-            REDIS_PASSWORD: conf.password,
-          }))
-          .otherwise(() => ({}));
-
-        const proto = clientAddonToProto(addon);
-
-        return {
-          base64_addon: btoa(proto.toJsonString()),
-          variables,
-          secrets,
-        };
-      });
-      setEncodedAddons(addons);
-
-      const { env } = data.app;
-      const variables = env
-        .filter((e) => !e.hidden && !e.deleted)
-        .reduce((acc: Record<string, string>, item) => {
-          acc[item.key] = item.value;
-          return acc;
-        }, {});
-      const secrets = env
-        .filter((e) => !e.deleted)
-        .reduce((acc: Record<string, string>, item) => {
-          if (item.hidden) {
-            acc[item.key] = item.value;
-          }
-          return acc;
-        }, {});
-      setFinalizedAppEnv({ variables, secrets });
-
-      if (!existingTemplate) {
-        setShowGHAModal(true);
-        return;
-      }
-
-      await createTemplateAndWorkflow({
-        app: proto,
-        variables,
-        secrets,
-        addons,
-      });
-      history.push(`/preview-environments`);
-    } catch (err) {
-      if (axios.isAxiosError(err) && err.response?.data?.error) {
-        setCreateError(err.response?.data?.error);
-        return;
-      }
-      setCreateError(
-        "An error occurred while validating your application. Please try again."
-      );
-    }
-  });
-
-  const createTemplateAndWorkflow = useCallback(
-    async ({
-      app,
-      variables,
-      secrets,
-      addons = [],
-      repo,
-    }: {
-      app: PorterApp | null;
-      variables: Record<string, string>;
-      secrets: Record<string, string>;
-      addons?: EncodedAddonWithEnv[];
-      repo?: RepoOverrides;
-    }) => {
-      try {
-        if (!app) {
-          return false;
-        }
-
-        await api.createAppTemplate(
-          "<token>",
-          {
-            b64_app_proto: btoa(app.toJsonString()),
-            variables,
-            secrets,
-            base_deployment_target_id: deploymentTarget.id,
-            addons,
-            ...(repo && {
-              git_overrides: {
-                git_repo_id: repo.id,
-                git_repo_name: repo.fullName,
-              },
-            }),
-          },
-          {
-            project_id: projectId,
-            cluster_id: clusterId,
-            porter_app_name: porterApp.name,
-          }
-        );
-
-        return true;
-      } catch (err) {
-        if (axios.isAxiosError(err) && err.response?.data?.error) {
-          setCreateError(err.response?.data?.error);
-          return false;
-        }
-
-        setCreateError(
-          "An error occurred while creating the CI workflow. Please try again."
-        );
-        return false;
-      }
-    },
-    []
-  );
-
   useEffect(() => {
     reset({
       app: withPreviewOverrides,
@@ -304,7 +89,7 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
   }, [withPreviewOverrides, latestSource, existingAddonsWithEnv]);
 
   return (
-    <FormProvider {...porterAppFormMethods}>
+    <>
       <TabSelector
         noBuffer
         options={[
@@ -327,39 +112,23 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
         }}
       />
       <Spacer y={1} />
-      <form onSubmit={onSubmit}>
-        {match(tab)
-          .with("services", () => (
-            <ServiceSettings buttonStatus={buttonStatus} />
-          ))
-          .with("variables", () => <Environment buttonStatus={buttonStatus} />)
-          .with("required-apps", () => (
-            <RequiredApps buttonStatus={buttonStatus} />
-          ))
-          .with("addons", () => <Addons buttonStatus={buttonStatus} />)
-          .exhaustive()}
-      </form>
+      {match(tab)
+        .with("services", () => <ServiceSettings buttonStatus={buttonStatus} />)
+        .with("variables", () => <Environment buttonStatus={buttonStatus} />)
+        .with("required-apps", () => (
+          <RequiredApps buttonStatus={buttonStatus} />
+        ))
+        .with("addons", () => <Addons buttonStatus={buttonStatus} />)
+        .exhaustive()}
       {showGHAModal && (
         <PreviewGHAModal
-          projectId={projectId}
-          clusterId={clusterId}
           onClose={() => {
             setShowGHAModal(false);
           }}
-          latestSource={latestSource}
-          appName={porterApp.name}
-          savePreviewConfig={async ({ repo }: { repo?: RepoOverrides }) =>
-            await createTemplateAndWorkflow({
-              app: validatedAppProto,
-              variables,
-              secrets,
-              addons: encodedAddons,
-              repo,
-            })
-          }
+          savePreviewConfig={savePreviewConfig}
           error={createError}
         />
       )}
-    </FormProvider>
+    </>
   );
 };

+ 11 - 12
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewGHAModal.tsx

@@ -16,23 +16,19 @@ import Select from "components/porter/Select";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import YamlEditor from "components/YamlEditor";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
 import RepositorySelector from "main/home/app-dashboard/build-settings/RepositorySelector";
 import { getPreviewGithubAction } from "main/home/app-dashboard/new-app-flow/utils";
 import FileSelector from "main/home/app-dashboard/validate-apply/build-settings/FileSelector";
 import { Code } from "main/home/managed-addons/tabs/shared";
-import { type SourceOptions } from "lib/porter-apps";
 
 import api from "shared/api";
 
-import { type RepoOverrides } from "./PreviewAppDataContainer";
+import { type RepoOverrides } from "../EnvTemplateContextProvider";
 
 type PreviewGHAModalProps = {
-  projectId: number;
-  clusterId: number;
-  appName: string;
-  latestSource: SourceOptions;
   onClose: () => void;
-  savePreviewConfig: ({ repo }: { repo?: RepoOverrides }) => Promise<boolean>;
+  savePreviewConfig: ({ repo }: { repo?: RepoOverrides }) => Promise<void>;
   error: string;
 };
 
@@ -46,21 +42,24 @@ const previewActionFormValidator = z.object({
 type PreviewActionForm = z.infer<typeof previewActionFormValidator>;
 
 export const PreviewGHAModal: React.FC<PreviewGHAModalProps> = ({
-  projectId,
-  clusterId,
-  appName,
-  latestSource,
   onClose,
   savePreviewConfig,
   error,
 }) => {
+  const history = useHistory();
+  const {
+    projectId,
+    clusterId,
+    latestSource,
+    porterApp: { name: appName },
+  } = useLatestRevision();
+
   const [step, setStep] = useState<"repo" | "confirm">(
     latestSource.type === "github" ? "confirm" : "repo"
   );
   const [showFileSelector, setShowFileSelector] = useState<boolean>(false);
   const [changePorterYamlPath, setChangePorterYamlPath] = useState(false);
 
-  const history = useHistory();
   const queryClient = useQueryClient();
   const {
     watch,

+ 50 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/RevisionLoader.tsx

@@ -0,0 +1,50 @@
+import React, { useEffect } from "react";
+import { useFormContext } from "react-hook-form";
+
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import { useAppWithPreviewOverrides } from "lib/hooks/useAppWithPreviewOverrides";
+
+import { type AppTemplateFormData } from "../EnvTemplateContextProvider";
+
+export const RevisionLoader: React.FC<{
+  children: React.ReactNode;
+}> = ({ children }) => {
+  const { latestProto, porterApp, latestSource, servicesFromYaml, appEnv } =
+    useLatestRevision();
+  const { reset } = useFormContext<AppTemplateFormData>();
+
+  const withPreviewOverrides = useAppWithPreviewOverrides({
+    latestApp: latestProto,
+    detectedServices: servicesFromYaml,
+    appEnv,
+  });
+
+  useEffect(() => {
+    // we don't store versions of build settings because they are stored in the db, so we just have to use the latest version
+    // however, for image settings, we can pull image repo and tag from the proto
+    const newSource =
+      porterApp.image_repo_uri && latestProto.image
+        ? {
+            type: "docker-registry" as const,
+            image: {
+              repository: latestProto.image.repository,
+              tag: latestProto.image.tag,
+            },
+          }
+        : latestSource;
+
+    reset({
+      app: withPreviewOverrides,
+      source: newSource,
+      deletions: {
+        envGroupNames: [],
+        serviceNames: [],
+        predeploy: [],
+        initialDeploy: [],
+      },
+      redeployOnSave: false,
+    });
+  }, [withPreviewOverrides]);
+
+  return <>{children}</>;
+};

+ 42 - 25
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/SetupApp.tsx

@@ -15,7 +15,9 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import pull_request from "assets/pull_request_icon.svg";
 
+import { EnvTemplateContextProvider } from "../EnvTemplateContextProvider";
 import { existingTemplateWithEnvValidator } from "../types";
+import { CreateTemplate } from "./CreateTemplate";
 import { PreviewAppDataContainer } from "./PreviewAppDataContainer";
 
 type Props = RouteComponentProps;
@@ -73,35 +75,50 @@ const SetupApp: React.FC<Props> = ({ location }) => {
   );
 
   if (!appName) {
-    return null;
-  }
-
-  return (
-    <ClusterContextProvider clusterId={currentCluster?.id} refetchInterval={0}>
-      <LatestRevisionProvider appName={appName}>
+    return (
+      <ClusterContextProvider
+        clusterId={currentCluster?.id}
+        refetchInterval={0}
+      >
         <CenterWrapper>
           <Div>
-            <StyledConfigureTemplate>
-              <Back to="/preview-environments" />
-              <DashboardHeader
-                prefix={<Icon src={pull_request} />}
-                title={`Preview apps for ${appName}`}
-                description="Set preview specific configuration for this app below. Any newly created preview apps will use these settings."
-                capitalize={false}
-                disableLineBreak
-              />
-              <DarkMatter />
-              {match(templateRes)
-                .with({ status: "loading" }, () => <Loading />)
-                .with({ status: "success" }, ({ data }) => {
-                  return <PreviewAppDataContainer existingTemplate={data} />;
-                })
-                .otherwise(() => null)}
-              <Spacer y={3} />
-            </StyledConfigureTemplate>
+            <EnvTemplateContextProvider shouldShowGHAModal>
+              <CreateTemplate />
+            </EnvTemplateContextProvider>
           </Div>
         </CenterWrapper>
-      </LatestRevisionProvider>
+      </ClusterContextProvider>
+    );
+  }
+
+  return (
+    <ClusterContextProvider clusterId={currentCluster?.id} refetchInterval={0}>
+      <EnvTemplateContextProvider>
+        <LatestRevisionProvider appName={appName}>
+          <CenterWrapper>
+            <Div>
+              <StyledConfigureTemplate>
+                <Back to="/preview-environments" />
+                <DashboardHeader
+                  prefix={<Icon src={pull_request} />}
+                  title={`Preview environments for ${appName}`}
+                  description="Set preview specific configuration for this app below. Any newly created preview environments will use these settings."
+                  capitalize={false}
+                  disableLineBreak
+                />
+                <DarkMatter />
+                {match(templateRes)
+                  .with({ status: "loading" }, () => <Loading />)
+                  .with({ status: "success" }, ({ data }) => {
+                    return <PreviewAppDataContainer existingTemplate={data} />;
+                  })
+                  .otherwise(() => null)}
+                <Spacer y={3} />
+              </StyledConfigureTemplate>
+            </Div>
+          </CenterWrapper>
+        </LatestRevisionProvider>
+      </EnvTemplateContextProvider>
     </ClusterContextProvider>
   );
 };

+ 5 - 4
dashboard/src/main/home/managed-addons/AddonListRow.tsx

@@ -7,16 +7,17 @@ import { match } from "ts-pattern";
 import Spacer from "components/porter/Spacer";
 import { type ClientAddon } from "lib/addons";
 
+import box from "assets/box.png";
 import postgresql from "assets/postgresql.svg";
 import redis from "assets/redis.svg";
 
-import { type AppTemplateFormData } from "../cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer";
+import { type AppTemplateFormData } from "../cluster-dashboard/preview-environments/v2/EnvTemplateContextProvider";
 import { PostgresTabs } from "./tabs/PostgresTabs";
 import { RedisTabs } from "./tabs/RedisTabs";
 
 type AddonRowProps = {
   index: number;
-  addon: ClientAddon;
+  addon: Omit<ClientAddon, "template">;
   update: UseFieldArrayUpdate<AppTemplateFormData, "addons">;
   remove: (index: number) => void;
 };
@@ -31,7 +32,7 @@ export const AddonListRow: React.FC<AddonRowProps> = ({
     match(type)
       .with("postgres", () => <Icon src={postgresql} />)
       .with("redis", () => <Icon src={redis} />)
-      .exhaustive();
+      .otherwise(() => <Icon src={box} />);
 
   return (
     <>
@@ -98,7 +99,7 @@ export const AddonListRow: React.FC<AddonRowProps> = ({
                 .with({ config: { type: "redis" } }, (ao) => (
                   <RedisTabs index={index} addon={ao} />
                 ))
-                .exhaustive()}
+                .otherwise(() => null)}
             </div>
           </StyledSourceBox>
         )}

+ 1 - 1
dashboard/src/main/home/managed-addons/AddonsList.tsx

@@ -16,12 +16,12 @@ import Modal from "components/porter/Modal";
 import Select from "components/porter/Select";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { type AppTemplateFormData } from "main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer";
 import { defaultClientAddon } from "lib/addons";
 
 import postgresql from "assets/postgresql.svg";
 import redis from "assets/redis.svg";
 
+import { type AppTemplateFormData } from "../cluster-dashboard/preview-environments/v2/EnvTemplateContextProvider";
 import { AddonListRow } from "./AddonListRow";
 
 const addAddonFormValidator = z.object({

+ 93 - 48
dashboard/src/main/home/managed-addons/tabs/PostgresTabs.tsx

@@ -1,6 +1,6 @@
 import React, { useMemo, useState } from "react";
 import { Controller, useFormContext } from "react-hook-form";
-import { match } from "ts-pattern";
+import { match, P } from "ts-pattern";
 
 import CopyToClipboard from "components/CopyToClipboard";
 import { ControlledInput } from "components/porter/ControlledInput";
@@ -8,7 +8,7 @@ import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import TabSelector from "components/TabSelector";
 import IntelligentSlider from "main/home/app-dashboard/validate-apply/services-settings/tabs/IntelligentSlider";
-import { type AppTemplateFormData } from "main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer";
+import { type AppTemplateFormData } from "main/home/cluster-dashboard/preview-environments/v2/EnvTemplateContextProvider";
 import { useClusterContext } from "main/home/infrastructure-dashboard/ClusterContextProvider";
 import { type ClientAddon } from "lib/addons";
 import { getServiceResourceAllowances } from "lib/porter-apps/services";
@@ -19,7 +19,7 @@ import { Code, CopyContainer, CopyIcon, IdContainer } from "./shared";
 
 type Props = {
   index: number;
-  addon: ClientAddon & {
+  addon: Omit<ClientAddon, "template"> & {
     config: {
       type: "postgres";
     };
@@ -102,56 +102,101 @@ export const PostgresTabs: React.FC<Props> = ({ index }) => {
             <Controller
               name={`addons.${index}.config.cpuCores`}
               control={control}
-              render={({ field: { value, onChange } }) => (
-                <IntelligentSlider
-                  label="CPUs: "
-                  unit="Cores"
-                  min={0.01}
-                  max={maxCpuCores}
-                  color={"#3f51b5"}
-                  value={value.value.toString()}
-                  setValue={(e) => {
-                    onChange({
-                      ...value,
-                      value: e,
-                    });
-                  }}
-                  step={0.1}
-                  disabled={value.readOnly}
-                  disabledTooltip={
-                    "You may only edit this field in your porter.yaml."
-                  }
-                  isSmartOptimizationOn={false}
-                  decimalsToRoundTo={2}
-                />
-              )}
+              render={({ field: { value, onChange } }) =>
+                match(value)
+                  .with(P.number, (v) => (
+                    <IntelligentSlider
+                      label="CPUs: "
+                      unit="Cores"
+                      min={0.01}
+                      max={maxCpuCores}
+                      color={"#3f51b5"}
+                      value={v.toString()}
+                      setValue={(e) => {
+                        onChange(e);
+                      }}
+                      step={0.1}
+                      disabled={false}
+                      disabledTooltip={
+                        "You may only edit this field in your porter.yaml."
+                      }
+                      isSmartOptimizationOn={false}
+                      decimalsToRoundTo={2}
+                    />
+                  ))
+                  .otherwise((v) => (
+                    <IntelligentSlider
+                      label="CPUs: "
+                      unit="Cores"
+                      min={0.01}
+                      max={maxCpuCores}
+                      color={"#3f51b5"}
+                      value={v.value.toString()}
+                      setValue={(e) => {
+                        onChange({
+                          ...v,
+                          value: e,
+                        });
+                      }}
+                      step={0.1}
+                      disabled={v.readOnly}
+                      disabledTooltip={
+                        "You may only edit this field in your porter.yaml."
+                      }
+                      isSmartOptimizationOn={false}
+                      decimalsToRoundTo={2}
+                    />
+                  ))
+              }
             />
             <Spacer y={1} />
             <Controller
               name={`addons.${index}.config.ramMegabytes`}
               control={control}
-              render={({ field: { value, onChange } }) => (
-                <IntelligentSlider
-                  label="RAM: "
-                  unit="MB"
-                  min={1}
-                  max={maxRamMegabytes}
-                  color={"#3f51b5"}
-                  value={value.value.toString()}
-                  setValue={(e) => {
-                    onChange({
-                      ...value,
-                      value: e,
-                    });
-                  }}
-                  step={10}
-                  disabled={value.readOnly}
-                  disabledTooltip={
-                    "You may only edit this field in your porter.yaml."
-                  }
-                  isSmartOptimizationOn={false}
-                />
-              )}
+              render={({ field: { value, onChange } }) =>
+                match(value)
+                  .with(P.number, (v) => (
+                    <IntelligentSlider
+                      label="RAM: "
+                      unit="MB"
+                      min={1}
+                      max={maxRamMegabytes}
+                      color={"#3f51b5"}
+                      value={v.toString()}
+                      setValue={(e) => {
+                        onChange(e);
+                      }}
+                      step={10}
+                      disabled={false}
+                      disabledTooltip={
+                        "You may only edit this field in your porter.yaml."
+                      }
+                      isSmartOptimizationOn={false}
+                    />
+                  ))
+                  .otherwise((v) => (
+                    <IntelligentSlider
+                      label="RAM: "
+                      unit="MB"
+                      min={1}
+                      max={maxRamMegabytes}
+                      color={"#3f51b5"}
+                      value={v.value.toString()}
+                      setValue={(e) => {
+                        onChange({
+                          ...v,
+                          value: e,
+                        });
+                      }}
+                      step={10}
+                      disabled={v.readOnly}
+                      disabledTooltip={
+                        "You may only edit this field in your porter.yaml."
+                      }
+                      isSmartOptimizationOn={false}
+                    />
+                  ))
+              }
             />
           </>
         ))

+ 93 - 48
dashboard/src/main/home/managed-addons/tabs/RedisTabs.tsx

@@ -1,6 +1,6 @@
 import React, { useMemo, useState } from "react";
 import { Controller, useFormContext } from "react-hook-form";
-import { match } from "ts-pattern";
+import { match, P } from "ts-pattern";
 
 import CopyToClipboard from "components/CopyToClipboard";
 import { ControlledInput } from "components/porter/ControlledInput";
@@ -8,7 +8,7 @@ import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import TabSelector from "components/TabSelector";
 import IntelligentSlider from "main/home/app-dashboard/validate-apply/services-settings/tabs/IntelligentSlider";
-import { type AppTemplateFormData } from "main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer";
+import { type AppTemplateFormData } from "main/home/cluster-dashboard/preview-environments/v2/EnvTemplateContextProvider";
 import { useClusterContext } from "main/home/infrastructure-dashboard/ClusterContextProvider";
 import { type ClientAddon } from "lib/addons";
 import { getServiceResourceAllowances } from "lib/porter-apps/services";
@@ -19,7 +19,7 @@ import { Code, CopyContainer, CopyIcon, IdContainer } from "./shared";
 
 type Props = {
   index: number;
-  addon: ClientAddon & {
+  addon: Omit<ClientAddon, "template"> & {
     config: {
       type: "redis";
     };
@@ -92,56 +92,101 @@ export const RedisTabs: React.FC<Props> = ({ index }) => {
             <Controller
               name={`addons.${index}.config.cpuCores`}
               control={control}
-              render={({ field: { value, onChange } }) => (
-                <IntelligentSlider
-                  label="CPUs: "
-                  unit="Cores"
-                  min={0.01}
-                  max={maxCpuCores}
-                  color={"#3f51b5"}
-                  value={value.value.toString()}
-                  setValue={(e) => {
-                    onChange({
-                      ...value,
-                      value: e,
-                    });
-                  }}
-                  step={0.1}
-                  disabled={value.readOnly}
-                  disabledTooltip={
-                    "You may only edit this field in your porter.yaml."
-                  }
-                  isSmartOptimizationOn={false}
-                  decimalsToRoundTo={2}
-                />
-              )}
+              render={({ field: { value, onChange } }) =>
+                match(value)
+                  .with(P.number, (v) => (
+                    <IntelligentSlider
+                      label="CPUs: "
+                      unit="Cores"
+                      min={0.01}
+                      max={maxCpuCores}
+                      color={"#3f51b5"}
+                      value={v.toString()}
+                      setValue={(e) => {
+                        onChange(e);
+                      }}
+                      step={0.1}
+                      disabled={false}
+                      disabledTooltip={
+                        "You may only edit this field in your porter.yaml."
+                      }
+                      isSmartOptimizationOn={false}
+                      decimalsToRoundTo={2}
+                    />
+                  ))
+                  .otherwise((v) => (
+                    <IntelligentSlider
+                      label="CPUs: "
+                      unit="Cores"
+                      min={0.01}
+                      max={maxCpuCores}
+                      color={"#3f51b5"}
+                      value={v.value.toString()}
+                      setValue={(e) => {
+                        onChange({
+                          ...v,
+                          value: e,
+                        });
+                      }}
+                      step={0.1}
+                      disabled={v.readOnly}
+                      disabledTooltip={
+                        "You may only edit this field in your porter.yaml."
+                      }
+                      isSmartOptimizationOn={false}
+                      decimalsToRoundTo={2}
+                    />
+                  ))
+              }
             />
             <Spacer y={1} />
             <Controller
               name={`addons.${index}.config.ramMegabytes`}
               control={control}
-              render={({ field: { value, onChange } }) => (
-                <IntelligentSlider
-                  label="RAM: "
-                  unit="MB"
-                  min={1}
-                  max={maxRamMegabytes}
-                  color={"#3f51b5"}
-                  value={value.value.toString()}
-                  setValue={(e) => {
-                    onChange({
-                      ...value,
-                      value: e,
-                    });
-                  }}
-                  step={10}
-                  disabled={value.readOnly}
-                  disabledTooltip={
-                    "You may only edit this field in your porter.yaml."
-                  }
-                  isSmartOptimizationOn={false}
-                />
-              )}
+              render={({ field: { value, onChange } }) =>
+                match(value)
+                  .with(P.number, (v) => (
+                    <IntelligentSlider
+                      label="RAM: "
+                      unit="MB"
+                      min={1}
+                      max={maxRamMegabytes}
+                      color={"#3f51b5"}
+                      value={v.toString()}
+                      setValue={(e) => {
+                        onChange(e);
+                      }}
+                      step={10}
+                      disabled={false}
+                      disabledTooltip={
+                        "You may only edit this field in your porter.yaml."
+                      }
+                      isSmartOptimizationOn={false}
+                    />
+                  ))
+                  .otherwise((v) => (
+                    <IntelligentSlider
+                      label="RAM: "
+                      unit="MB"
+                      min={1}
+                      max={maxRamMegabytes}
+                      color={"#3f51b5"}
+                      value={v.value.toString()}
+                      setValue={(e) => {
+                        onChange({
+                          ...v,
+                          value: e,
+                        });
+                      }}
+                      step={10}
+                      disabled={v.readOnly}
+                      disabledTooltip={
+                        "You may only edit this field in your porter.yaml."
+                      }
+                      isSmartOptimizationOn={false}
+                    />
+                  ))
+              }
             />
           </>
         ))

+ 2 - 2
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -281,7 +281,7 @@ class Sidebar extends Component<PropsType, StateType> {
               <Container row spaced style={{ width: "100%" }}>
                 <Container row>
                   <Img src={pr_icon} />
-                  Preview apps
+                  Preview environments
                 </Container>
                 {(currentProject.sandbox_enabled ||
                   !currentProject.preview_envs_enabled) && (
@@ -385,7 +385,7 @@ class Sidebar extends Component<PropsType, StateType> {
               <Container row spaced style={{ width: "100%" }}>
                 <Container row>
                   <Img src={pr_icon} />
-                  Preview apps
+                  Preview environments
                 </Container>
                 {!currentProject.preview_envs_enabled && <Badge>Beta</Badge>}
               </Container>

+ 6 - 3
dashboard/src/shared/DeploymentTargetContext.tsx

@@ -34,12 +34,15 @@ const DeploymentTargetProvider = ({
 }: {
   children: JSX.Element;
 }): JSX.Element => {
-  const { search } = useLocation();
+  const location = useLocation();
   const { currentProject, currentCluster, setCurrentCluster } =
     useContext(Context);
-  const queryParams = new URLSearchParams(search);
 
-  const deploymentTargetID = queryParams.get("target");
+  const deploymentTargetID = useMemo(() => {
+    const queryParams = new URLSearchParams(location.search);
+    return queryParams.get("target");
+  }, [location.search]);
+
   const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } =
     useDefaultDeploymentTarget();
 

+ 11 - 0
dashboard/src/shared/api.tsx

@@ -1075,6 +1075,16 @@ const createAppTemplate = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/templates`;
 });
 
+const listTemplateEnvironments = baseApi<
+  Record<string, unknown>,
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/templates`;
+});
+
 const updateApp = baseApi<
   {
     deployment_target_id: string;
@@ -3876,6 +3886,7 @@ export default {
   getBranchHead,
   createApp,
   createAppTemplate,
+  listTemplateEnvironments,
   updateApp,
   appRun,
   updateBuildSettings,

+ 1 - 1
go.mod

@@ -89,7 +89,7 @@ require (
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
 	github.com/ory/client-go v1.9.0
-	github.com/porter-dev/api-contracts v0.2.161
+	github.com/porter-dev/api-contracts v0.2.164
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1570,8 +1570,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.161 h1:kf1ZcS1032eLabBzjwDs9SVcecXwUxJ2mJUkRl9C8jk=
-github.com/porter-dev/api-contracts v0.2.161/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
+github.com/porter-dev/api-contracts v0.2.164 h1:99Y96YH9CfAl/aPjnqXbsiEgMHUFxDM9wC5G5sQnmyQ=
+github.com/porter-dev/api-contracts v0.2.164/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=