Просмотр исходного кода

reorganize preview form under tabs (#4090)

ianedwards 2 лет назад
Родитель
Сommit
a67ad76384

+ 63 - 121
api/server/handlers/porter_app/create_app_template.go

@@ -4,8 +4,8 @@ import (
 	"context"
 	"encoding/base64"
 	"net/http"
-	"time"
 
+	"connectrpc.com/connect"
 	"github.com/google/uuid"
 	"github.com/porter-dev/api-contracts/generated/go/helpers"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
@@ -41,18 +41,25 @@ func NewCreateAppTemplateHandler(
 	}
 }
 
+// Base64AddonWithEnvVars is a struct that contains a base64 encoded addon proto and its env vars
+// These env vars will be used to create an env group that is attached to the addon
+type Base64AddonWithEnvVars struct {
+	Base64Addon string            `json:"base64_addon"`
+	Variables   map[string]string `json:"variables"`
+	Secrets     map[string]string `json:"secrets"`
+}
+
 // CreateAppTemplateRequest is the request object for the /app-template POST endpoint
 type CreateAppTemplateRequest struct {
-	B64AppProto            string            `json:"b64_app_proto"`
-	Variables              map[string]string `json:"variables"`
-	Secrets                map[string]string `json:"secrets"`
-	BaseDeploymentTargetID string            `json:"base_deployment_target_id"`
+	B64AppProto            string                   `json:"b64_app_proto"`
+	Variables              map[string]string        `json:"variables"`
+	Secrets                map[string]string        `json:"secrets"`
+	BaseDeploymentTargetID string                   `json:"base_deployment_target_id"`
+	Addons                 []Base64AddonWithEnvVars `json:"addons"`
 }
 
 // CreateAppTemplateResponse is the response object for the /app-template POST endpoint
-type CreateAppTemplateResponse struct {
-	AppTemplateID string `json:"app_template_id"`
-}
+type CreateAppTemplateResponse struct{}
 
 // ServeHTTP creates or updates an app template for a given porter app
 func (c *CreateAppTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -108,55 +115,6 @@ func (c *CreateAppTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	porterApps, err := c.Repo().PorterApp().ReadPorterAppsByProjectIDAndName(project.ID, appName)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting porter app from repo")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-	if len(porterApps) == 0 {
-		err := telemetry.Error(ctx, span, err, "no porter apps returned")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-	if len(porterApps) > 1 {
-		err := telemetry.Error(ctx, span, err, "multiple porter apps returned; unable to determine which one to use")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
-	if porterApps[0].ID == 0 {
-		err := telemetry.Error(ctx, span, err, "porter app id is missing")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-id", Value: porterApps[0].ID})
-
-	var appTemplate *models.AppTemplate
-
-	existingAppTemplate, err := c.Repo().AppTemplate().AppTemplateByPorterAppID(
-		project.ID,
-		porterApps[0].ID,
-	)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error checking for existing app template")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	if existingAppTemplate.ID != uuid.Nil {
-		appTemplate = existingAppTemplate
-		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "update-app-template", Value: true})
-	}
-	if appTemplate == nil {
-		appTemplate = &models.AppTemplate{
-			ProjectID:   int(project.ID),
-			PorterAppID: int(porterApps[0].ID),
-		}
-		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "update-app-template", Value: false})
-	}
-
 	protoWithoutDefaultAppEnvGroups, err := filterDefaultAppEnvGroups(ctx, request.B64AppProto, agent)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error filtering default app env groups")
@@ -164,59 +122,53 @@ func (c *CreateAppTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	appTemplate.Base64App = protoWithoutDefaultAppEnvGroups
-	appTemplate.BaseDeploymentTargetID = baseDeploymentTarget
-
-	updatedAppTemplate, err := c.Repo().AppTemplate().CreateAppTemplate(appTemplate)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error creating app template")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
+	var addonTemplates []*porterv1.AddonWithEnvVars
+	for _, addon := range request.Addons {
+		decoded, err := base64.StdEncoding.DecodeString(addon.Base64Addon)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error decoding base64 addon")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
 
-	if updatedAppTemplate == nil {
-		err := telemetry.Error(ctx, span, err, "updated app template is nil")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-	if updatedAppTemplate.ID == uuid.Nil {
-		err := telemetry.Error(ctx, span, err, "updated app template id is nil")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
+		addonProto := &porterv1.Addon{}
+		err = helpers.UnmarshalContractObject(decoded, addonProto)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error unmarshalling addon proto")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
 
-	previewTemplateEnvName, err := porter_app.AppTemplateEnvGroupName(ctx, appName, cluster.ID, c.Repo().PorterApp())
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to get app template env group name")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
+		addonTemplates = append(addonTemplates, &porterv1.AddonWithEnvVars{
+			Addon: addonProto,
+			EnvVars: &porterv1.EnvGroupVariables{
+				Normal: addon.Variables,
+				Secret: addon.Secrets,
+			},
+		})
+	}
+
+	updateAppTemplateReq := connect.NewRequest(&porterv1.UpdateAppTemplateRequest{
+		ProjectId:   int64(project.ID),
+		AppName:     appName,
+		AppTemplate: protoWithoutDefaultAppEnvGroups,
+		AppEnv: &porterv1.EnvGroupVariables{
+			Normal: request.Variables,
+			Secret: request.Secrets,
+		},
+		AddonTemplates:         addonTemplates,
+		BaseDeploymentTargetId: baseDeploymentTarget.String(),
+	})
 
-	envGroup, err := environment_groups.LatestBaseEnvironmentGroup(ctx, agent, previewTemplateEnvName)
+	updateAppTemplateRes, err := c.Config().ClusterControlPlaneClient.UpdateAppTemplate(ctx, updateAppTemplateReq)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to get latest base environment group")
+		err := telemetry.Error(ctx, span, err, "error updating app template")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	if envGroup.Name == "" {
-		envGroup = environment_groups.EnvironmentGroup{
-			Name:         previewTemplateEnvName,
-			CreatedAtUTC: time.Now().UTC(),
-		}
-	}
-	envGroup.Variables = request.Variables
-	envGroup.SecretVariables = request.Secrets
-
-	additionalEnvGroupLabels := map[string]string{
-		LabelKey_AppName: appName,
-		environment_groups.LabelKey_DefaultAppEnvironment: "true",
-		LabelKey_PorterManaged:                            "true",
-	}
-
-	err = environment_groups.CreateOrUpdateBaseEnvironmentGroup(ctx, agent, envGroup, additionalEnvGroupLabels)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to create or update base environment group")
+	if updateAppTemplateRes == nil || updateAppTemplateRes.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "error updating app template")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
@@ -238,9 +190,7 @@ func (c *CreateAppTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	res := &CreateAppTemplateResponse{
-		AppTemplateID: updatedAppTemplate.ID.String(),
-	}
+	res := &CreateAppTemplateResponse{}
 
 	c.WriteResult(w, r, res)
 }
@@ -248,35 +198,34 @@ func (c *CreateAppTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 // filterDefaultAppEnvGroups filters out any default app env groups found when creating an app template
 // app templates are based on the latest version of a given app, so it is possible for this env group to be included
 // however, the app template will get its own default env group when used to deploy to a preview environment
-func filterDefaultAppEnvGroups(ctx context.Context, b64AppProto string, agent *kubernetes.Agent) (string, error) {
+func filterDefaultAppEnvGroups(ctx context.Context, b64AppProto string, agent *kubernetes.Agent) (*porterv1.PorterApp, error) {
 	ctx, span := telemetry.NewSpan(ctx, "filter-default-app-env-groups")
 	defer span.End()
 
-	var finalAppProto string
+	appProto := &porterv1.PorterApp{}
 
 	if b64AppProto == "" {
-		return finalAppProto, telemetry.Error(ctx, span, nil, "b64 app proto is empty")
+		return appProto, telemetry.Error(ctx, span, nil, "b64 app proto is empty")
 	}
 	if agent == nil {
-		return finalAppProto, telemetry.Error(ctx, span, nil, "agent is nil")
+		return appProto, telemetry.Error(ctx, span, nil, "agent is nil")
 	}
 
 	decoded, err := base64.StdEncoding.DecodeString(b64AppProto)
 	if err != nil {
-		return finalAppProto, telemetry.Error(ctx, span, err, "error decoding base app")
+		return appProto, telemetry.Error(ctx, span, err, "error decoding base app")
 	}
 
-	appProto := &porterv1.PorterApp{}
 	err = helpers.UnmarshalContractObject(decoded, appProto)
 	if err != nil {
-		return finalAppProto, telemetry.Error(ctx, span, err, "error unmarshalling app proto")
+		return appProto, telemetry.Error(ctx, span, err, "error unmarshalling app proto")
 	}
 
 	filteredEnvGroups := []*porterv1.EnvGroup{}
 	for _, envGroup := range appProto.EnvGroups {
 		baseEnvGroup, err := environment_groups.LatestBaseEnvironmentGroup(ctx, agent, envGroup.Name)
 		if err != nil {
-			return finalAppProto, telemetry.Error(ctx, span, err, "unable to get latest base environment group")
+			return appProto, telemetry.Error(ctx, span, err, "unable to get latest base environment group")
 		}
 		if baseEnvGroup.DefaultAppEnvironment {
 			continue
@@ -287,12 +236,5 @@ func filterDefaultAppEnvGroups(ctx context.Context, b64AppProto string, agent *k
 
 	appProto.EnvGroups = filteredEnvGroups
 
-	encoded, err := helpers.MarshalContractObject(ctx, appProto)
-	if err != nil {
-		return finalAppProto, telemetry.Error(ctx, span, err, "error marshalling app proto")
-	}
-
-	finalAppProto = base64.StdEncoding.EncodeToString(encoded)
-
-	return finalAppProto, nil
+	return appProto, nil
 }

+ 61 - 0
dashboard/src/lib/hooks/useAppWithPreviewOverrides.ts

@@ -0,0 +1,61 @@
+import { useMemo } from "react";
+import { PorterApp } from "@porter-dev/api-contracts";
+
+import { type PopulatedEnvGroup } from "main/home/app-dashboard/validate-apply/app-settings/types";
+import {
+  applyPreviewOverrides,
+  clientAppFromProto,
+  type ClientPorterApp,
+} from "lib/porter-apps";
+import { type DetectedServices } from "lib/porter-apps/services";
+
+export const useAppWithPreviewOverrides = ({
+  latestApp,
+  detectedServices,
+  templateEnv,
+  existingTemplate,
+  appEnv,
+}: {
+  latestApp: PorterApp;
+  detectedServices: DetectedServices | null;
+  existingTemplate?: PorterApp;
+  templateEnv?: {
+    variables: Record<string, string>;
+    secret_variables: Record<string, string>;
+  };
+  appEnv?: PopulatedEnvGroup;
+}): ClientPorterApp => {
+  const withPreviewOverrides = useMemo(() => {
+    const proto =
+      existingTemplate ||
+      new PorterApp({
+        ...latestApp,
+        envGroups: [],
+      }); // clear out env groups, they won't get added to the template anyways
+
+    const variables = templateEnv ? templateEnv.variables : appEnv?.variables;
+    const secrets = templateEnv
+      ? templateEnv.secret_variables
+      : appEnv?.secret_variables;
+
+    return applyPreviewOverrides({
+      app: clientAppFromProto({
+        proto,
+        overrides: detectedServices,
+        variables,
+        secrets,
+        lockServiceDeletions: true,
+      }),
+      overrides: detectedServices?.previews,
+    });
+  }, [
+    latestApp,
+    detectedServices,
+    existingTemplate,
+    templateEnv,
+    appEnv?.variables,
+    appEnv?.secret_variables,
+  ]);
+
+  return withPreviewOverrides;
+};

+ 20 - 18
dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx

@@ -1,17 +1,19 @@
+import React from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useFormContext } from "react-hook-form";
+import { z } from "zod";
+
+import Button from "components/porter/Button";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import React, { useMemo } from "react";
-import Button from "components/porter/Button";
-import Error from "components/porter/Error";
-import { useFormContext } from "react-hook-form";
-import { PorterAppFormData, SourceOptions } from "lib/porter-apps";
-import { useLatestRevision } from "../LatestRevisionContext";
-import { useQuery } from "@tanstack/react-query";
+import { type PorterAppFormData, type SourceOptions } from "lib/porter-apps";
+
 import api from "shared/api";
-import { z } from "zod";
-import { populatedEnvGroup } from "../../validate-apply/app-settings/types";
+
 import EnvSettings from "../../validate-apply/app-settings/EnvSettings";
-import { ButtonStatus } from "../AppDataContainer";
+import { populatedEnvGroup } from "../../validate-apply/app-settings/types";
+import { type ButtonStatus } from "../AppDataContainer";
+import { useLatestRevision } from "../LatestRevisionContext";
 
 type Props = {
   latestSource: SourceOptions;
@@ -43,13 +45,13 @@ const Environment: React.FC<Props> = ({ latestSource, buttonStatus }) => {
         }
       );
 
-      const { environment_groups } = await z
+      const { environment_groups: envGroups } = await z
         .object({
           environment_groups: z.array(populatedEnvGroup).default([]),
         })
         .parseAsync(res.data);
 
-      return environment_groups;
+      return envGroups;
     }
   );
 
@@ -57,10 +59,13 @@ const Environment: React.FC<Props> = ({ latestSource, buttonStatus }) => {
     <>
       <Text size={16}>Environment variables</Text>
       <Spacer y={0.5} />
-      <Text color="helper">Shared among all services. All non-secret variables are also available at build time.</Text>
+      <Text color="helper">
+        Shared among all services. All non-secret variables are also available
+        at build time.
+      </Text>
       <EnvSettings
         appName={latestProto.name}
-        revision={previewRevision ? previewRevision : latestRevision} // get versions of env groups attached to preview revision if set
+        revision={previewRevision || latestRevision} // get versions of env groups attached to preview revision if set
         baseEnvGroups={baseEnvGroups}
         latestSource={latestSource}
         attachedEnvGroups={attachedEnvGroups}
@@ -70,10 +75,7 @@ const Environment: React.FC<Props> = ({ latestSource, buttonStatus }) => {
         type="submit"
         status={buttonStatus}
         loadingText={"Updating..."}
-        disabled={
-          isSubmitting ||
-          latestRevision.status === "CREATED"
-        }
+        disabled={isSubmitting || latestRevision.status === "CREATED"}
         disabledTooltipMessage="Please wait for the deploy to complete before updating environment variables"
       >
         Update app

+ 63 - 132
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/AppTemplateForm.tsx → dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer.tsx

@@ -1,51 +1,50 @@
 import React, { useCallback, useEffect, useMemo, useState } from "react";
 import { zodResolver } from "@hookform/resolvers/zod";
-import { PorterApp } from "@porter-dev/api-contracts";
-import { useQuery } from "@tanstack/react-query";
+import { type PorterApp } from "@porter-dev/api-contracts";
 import axios from "axios";
 import _ from "lodash";
 import { FormProvider, useForm } from "react-hook-form";
 import { Redirect, useHistory } from "react-router";
-import { z } from "zod";
+import { match } from "ts-pattern";
 
-import Button from "components/porter/Button";
 import Error from "components/porter/Error";
 import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import VerticalSteps from "components/porter/VerticalSteps";
+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 GithubActionModal from "main/home/app-dashboard/new-app-flow/GithubActionModal";
-import EnvSettings from "main/home/app-dashboard/validate-apply/app-settings/EnvSettings";
-import { populatedEnvGroup } from "main/home/app-dashboard/validate-apply/app-settings/types";
-import ServiceList from "main/home/app-dashboard/validate-apply/services-settings/ServiceList";
+import { useAppWithPreviewOverrides } from "lib/hooks/useAppWithPreviewOverrides";
 import {
-  applyPreviewOverrides,
-  clientAppFromProto,
   clientAppToProto,
   porterAppFormValidator,
   type PorterAppFormData,
   type SourceOptions,
 } from "lib/porter-apps";
-import {
-  defaultSerialized,
-  deserializeService,
-} from "lib/porter-apps/services";
 
 import api from "shared/api";
-import { useClusterResources } from "shared/ClusterResourcesContext";
+
+import { type ExistingTemplateWithEnv } from "../types";
+import { ServiceSettings } from "./ServiceSettings";
 
 type Props = {
-  existingTemplate: {
-    template: PorterApp;
-    env: {
-      variables: Record<string, string>;
-      secret_variables: Record<string, string>;
-    };
-  } | null;
+  existingTemplate: ExistingTemplateWithEnv | null;
 };
 
-const AppTemplateForm: React.FC<Props> = ({ existingTemplate }) => {
+const previewEnvSettingsTabs = [
+  "services",
+  "variables",
+  "addons",
+  "required-apps",
+] as const;
+
+type PreviewEnvSettingsTab = (typeof previewEnvSettingsTabs)[number];
+
+export const PreviewAppDataContainer: React.FC<Props> = ({
+  existingTemplate,
+}) => {
   const history = useHistory();
+
+  const [tab, setTab] = useState<PreviewEnvSettingsTab>("services");
   const [validatedAppProto, setValidatedAppProto] = useState<PorterApp | null>(
     null
   );
@@ -58,7 +57,6 @@ const AppTemplateForm: React.FC<Props> = ({ existingTemplate }) => {
     variables: {},
     secrets: {},
   });
-  const { currentClusterResources } = useClusterResources();
 
   const {
     porterApp,
@@ -70,28 +68,6 @@ const AppTemplateForm: React.FC<Props> = ({ existingTemplate }) => {
     deploymentTarget,
   } = useLatestRevision();
 
-  const { data: baseEnvGroups = [] } = useQuery(
-    ["getAllEnvGroups", projectId, clusterId],
-    async () => {
-      const res = await api.getAllEnvGroups(
-        "<token>",
-        {},
-        {
-          id: projectId,
-          cluster_id: clusterId,
-        }
-      );
-
-      const { environment_groups: envGroups } = await z
-        .object({
-          environment_groups: z.array(populatedEnvGroup).default([]),
-        })
-        .parseAsync(res.data);
-
-      return envGroups;
-    }
-  );
-
   const latestSource: SourceOptions = useMemo(() => {
     if (porterApp.image_repo_uri) {
       const [repository, tag] = porterApp.image_repo_uri.split(":");
@@ -113,27 +89,13 @@ const AppTemplateForm: React.FC<Props> = ({ existingTemplate }) => {
     };
   }, [porterApp]);
 
-  const withPreviewOverrides = useMemo(() => {
-    return applyPreviewOverrides({
-      app: clientAppFromProto({
-        proto: existingTemplate?.template
-          ? existingTemplate.template
-          : new PorterApp({
-              ...latestProto,
-              envGroups: [],
-            }), // clear out env groups, they won't get added to the template anyways
-        overrides: servicesFromYaml,
-        variables: existingTemplate
-          ? existingTemplate.env.variables
-          : appEnv?.variables,
-        secrets: existingTemplate
-          ? existingTemplate.env.secret_variables
-          : appEnv?.secret_variables,
-        lockServiceDeletions: true,
-      }),
-      overrides: servicesFromYaml?.previews,
-    });
-  }, [latestProto, existingTemplate?.template, appEnv, servicesFromYaml]);
+  const withPreviewOverrides = useAppWithPreviewOverrides({
+    latestApp: latestProto,
+    detectedServices: servicesFromYaml,
+    existingTemplate: existingTemplate?.template,
+    templateEnv: existingTemplate?.env,
+    appEnv,
+  });
 
   const porterAppFormMethods = useForm<PorterAppFormData>({
     reValidateMode: "onSubmit",
@@ -285,69 +247,40 @@ const AppTemplateForm: React.FC<Props> = ({ existingTemplate }) => {
 
   return (
     <FormProvider {...porterAppFormMethods}>
+      <TabSelector
+        noBuffer
+        options={[
+          { label: "App Services", value: "services" },
+          { label: "Environment Variables", value: "variables" },
+          // { label: "Required Apps", value: "required-apps" },
+          // { label: "Add-ons", value: "addons" },
+        ]}
+        currentTab={tab}
+        setCurrentTab={(tab: string) => {
+          if (tab === "services") {
+            setTab("services");
+          } else if (tab === "variables") {
+            setTab("variables");
+          } else if (tab === "required-apps") {
+            setTab("required-apps");
+          } else {
+            setTab("addons");
+          }
+        }}
+      />
+      <Spacer y={1} />
       <form onSubmit={onSubmit}>
-        <VerticalSteps
-          currentStep={3}
-          steps={[
-            <>
-              <Text size={16}>Application services</Text>
-              <Spacer y={0.5} />
-              <ServiceList
-                addNewText={"Add a new service"}
-                fieldArrayName={"app.services"}
-                internalNetworkingDetails={{
-                  namespace: deploymentTarget.namespace,
-                  appName: porterApp.name,
-                }}
-                allowAddServices={false}
-              />
-            </>,
-            <>
-              <Text size={16}>Environment variables (optional)</Text>
-              <Spacer y={0.5} />
-              <Text color="helper">
-                Specify environment variables shared among all services.
-              </Text>
-              <EnvSettings baseEnvGroups={baseEnvGroups} />
-            </>,
-            <>
-              <Text size={16}>Pre-deploy job (optional)</Text>
-              <Spacer y={0.5} />
-              <Text color="helper">
-                You may add a pre-deploy job to perform an operation before your
-                application services deploy each time, like a database
-                migration.
-              </Text>
-              <Spacer y={0.5} />
-              <ServiceList
-                addNewText={"Add a new pre-deploy job"}
-                prePopulateService={deserializeService({
-                  service: defaultSerialized({
-                    name: "pre-deploy",
-                    type: "predeploy",
-                    defaultCPU: currentClusterResources.defaultCPU,
-                    defaultRAM: currentClusterResources.defaultRAM,
-                  }),
-                })}
-                existingServiceNames={
-                  latestProto.predeploy ? ["pre-deploy"] : []
-                }
-                isPredeploy
-                fieldArrayName={"app.predeploy"}
-              />
-            </>,
-            <>
-              <Button
-                type="submit"
-                loadingText={"Saving..."}
-                width={"150px"}
-                status={buttonStatus}
-              >
-                {existingTemplate ? "Update Previews" : "Enable Previews"}
-              </Button>
-            </>,
-          ].filter((x) => x)}
-        />
+        {match(tab)
+          .with("services", () => (
+            <ServiceSettings buttonStatus={buttonStatus} />
+          ))
+          .with("variables", () => (
+            <Environment
+              latestSource={latestSource}
+              buttonStatus={buttonStatus}
+            />
+          ))
+          .otherwise(() => null)}
       </form>
       {showGHAModal && (
         <GithubActionModal
@@ -377,5 +310,3 @@ const AppTemplateForm: React.FC<Props> = ({ existingTemplate }) => {
     </FormProvider>
   );
 };
-
-export default AppTemplateForm;

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

@@ -0,0 +1,72 @@
+import React from "react";
+import _ from "lodash";
+import { useFormContext } from "react-hook-form";
+
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ButtonStatus } from "main/home/app-dashboard/app-view/AppDataContainer";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import ServiceList from "main/home/app-dashboard/validate-apply/services-settings/ServiceList";
+import { type PorterAppFormData } from "lib/porter-apps";
+import {
+  defaultSerialized,
+  deserializeService,
+} from "lib/porter-apps/services";
+
+import { useClusterResources } from "shared/ClusterResourcesContext";
+
+type Props = {
+  buttonStatus: ButtonStatus;
+};
+
+export const ServiceSettings: React.FC<Props> = ({ buttonStatus }) => {
+  const { deploymentTarget, porterApp, latestProto } = useLatestRevision();
+  const { currentClusterResources } = useClusterResources();
+
+  const {
+    formState: { isSubmitting },
+  } = useFormContext<PorterAppFormData>();
+
+  return (
+    <>
+      <Text size={16}>Pre-deploy job</Text>
+      <Spacer y={0.5} />
+      <ServiceList
+        addNewText={"Add a new pre-deploy job"}
+        prePopulateService={deserializeService({
+          service: defaultSerialized({
+            name: "pre-deploy",
+            type: "predeploy",
+            defaultCPU: currentClusterResources.defaultCPU,
+            defaultRAM: currentClusterResources.defaultRAM,
+          }),
+        })}
+        existingServiceNames={latestProto.predeploy ? ["pre-deploy"] : []}
+        isPredeploy
+        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: deploymentTarget.namespace,
+          appName: porterApp.name,
+        }}
+        allowAddServices={false}
+      />
+      <Spacer y={0.75} />
+      <Button
+        type="submit"
+        status={buttonStatus}
+        loadingText={"Updating..."}
+        disabled={isSubmitting}
+      >
+        Update app
+      </Button>
+    </>
+  );
+};

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

@@ -16,7 +16,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import pull_request from "assets/pull_request_icon.svg";
 
-import AppTemplateForm from "./AppTemplateForm";
+import { PreviewAppDataContainer } from "./PreviewAppDataContainer";
 
 type Props = RouteComponentProps;
 
@@ -109,7 +109,7 @@ const SetupApp: React.FC<Props> = ({ location }) => {
             {match(templateRes)
               .with({ status: "loading" }, () => <Loading />)
               .with({ status: "success" }, ({ data }) => {
-                return <AppTemplateForm existingTemplate={data} />;
+                return <PreviewAppDataContainer existingTemplate={data} />;
               })
               .otherwise(() => null)}
             <Spacer y={3} />

+ 9 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/types.ts

@@ -0,0 +1,9 @@
+import { type PorterApp } from "@porter-dev/api-contracts";
+
+export type ExistingTemplateWithEnv = {
+  template: PorterApp;
+  env: {
+    variables: Record<string, string>;
+    secret_variables: Record<string, string>;
+  };
+};

+ 1 - 1
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.68
+	github.com/porter-dev/api-contracts v0.2.71
 	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

@@ -1520,8 +1520,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.68 h1:OeU3RQAI6IpGC99UdDalrlRnNn7nevoxjm+Gm6n8PEY=
-github.com/porter-dev/api-contracts v0.2.68/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.71 h1:A9JRjOzXLx9U2ECCvUORZEhLygExDyB0kafe+TnUsM4=
+github.com/porter-dev/api-contracts v0.2.71/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 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=